mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Compare commits
87 Commits
2026.06.28
...
2026.08.18
| Author | SHA1 | Date | |
|---|---|---|---|
| 72e8f5031f | |||
| f3c464fad5 | |||
| b10bb6103a | |||
| 8c2990e68a | |||
| ac46fff6d9 | |||
| 05c21326b3 | |||
| 99da62dcbb | |||
| d0ad36baad | |||
| d2095caea2 | |||
| e15aff3339 | |||
| fccd207799 | |||
| 6461924bf8 | |||
| c68fcaddd1 | |||
| a4454ac460 | |||
| 75fe1f0c11 | |||
| aac9c63a36 | |||
| 3444b1605b | |||
| 59cf84ae1a | |||
| 99b6452c8e | |||
| 5826d0dc2b | |||
| c8fb5bbc27 | |||
| 56194f0bf3 | |||
| de57484fc9 | |||
| 7082858237 | |||
| 97d1cc865c | |||
| 482381d6b9 | |||
| 0445f5858b | |||
| 6551f7ad58 | |||
| 06c63ec6e5 | |||
| d66b04ccf5 | |||
| 2744f36b44 | |||
| ff1b73a576 | |||
| 1a09dbd686 | |||
| 08dccd98fb | |||
| 1f20aaee94 | |||
| 8a29f3a084 | |||
| 1839e5484d | |||
| fceac97033 | |||
| a13762aa61 | |||
| 50250f8374 | |||
| 926d392926 | |||
| 7f13784445 | |||
| 4aa20890d4 | |||
| e061a8a5ba | |||
| 13cb65d931 | |||
| 1b02a99510 | |||
| 4e27600329 | |||
| ebcfe577bc | |||
| 3bd2c3e366 | |||
| 1c7261ab59 | |||
| 4cf2b1b0d0 | |||
| 707f700609 | |||
| c519f45908 | |||
| a23d1689e3 | |||
| 4b05022b91 | |||
| f3d670e288 | |||
| 0dc9b0b3d6 | |||
| 96f5ffe34a | |||
| 1eafecd1cc | |||
| 8fe81dea18 | |||
| f054d84108 | |||
| edf101faa0 | |||
| fa02717e12 | |||
| 9ca78be199 | |||
| 8071611a84 | |||
| 220f991fae | |||
| 6d0528783c | |||
| c104e30451 | |||
| fdfbfed5e2 | |||
| 3ea4732c5d | |||
| e2c777842e | |||
| c34a18de7a | |||
| d6bcf182c5 | |||
| ad90609c9b | |||
| 5315630ab0 | |||
| 54463baf0e | |||
| b00d4785ee | |||
| 96e88a3555 | |||
| 49a46a7d1c | |||
| 961b54aa83 | |||
| e0549d6c24 | |||
| f315b75bb2 | |||
| c2c129db61 | |||
| 363f159a0a | |||
| 38c0ca22f4 | |||
| 24ae8f0742 | |||
| 0a946cc352 |
@@ -1 +0,0 @@
|
|||||||
blank_discussions_enabled: false
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
name: ⚙️ Configuration Help
|
|
||||||
description: Get help with MeTube configuration and setup
|
|
||||||
title: "[Config]: "
|
|
||||||
labels: ["configuration", "help"]
|
|
||||||
assignees: []
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
id: prerequisites
|
|
||||||
attributes:
|
|
||||||
label: Prerequisites
|
|
||||||
description: Please confirm you have completed these steps before asking for configuration help
|
|
||||||
options:
|
|
||||||
- label: I have searched existing discussions and issues for similar configuration problems
|
|
||||||
required: true
|
|
||||||
- label: I have read the [configuration section](https://github.com/alexta69/metube#%EF%B8%8F-configuration-via-environment-variables) in the README
|
|
||||||
required: true
|
|
||||||
- label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## Configuration Resources
|
|
||||||
|
|
||||||
Before asking for help, please check these resources:
|
|
||||||
- **[Configuration Guide](https://github.com/alexta69/metube#%EF%B8%8F-configuration-via-environment-variables)** - All available environment variables
|
|
||||||
- **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
|
|
||||||
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: configuration-goal
|
|
||||||
attributes:
|
|
||||||
label: What are you trying to configure?
|
|
||||||
description: Describe what you want to achieve with your MeTube configuration
|
|
||||||
placeholder: |
|
|
||||||
What specific behavior are you trying to achieve?
|
|
||||||
What's not working as expected?
|
|
||||||
What have you tried so far?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Your configuration, environment details, errors, and any other helpful information
|
|
||||||
placeholder: |
|
|
||||||
Current Configuration:
|
|
||||||
```yaml
|
|
||||||
# Your docker-compose.yml or environment variables
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment Details:
|
|
||||||
- MeTube version: [e.g., latest, specific version]
|
|
||||||
- Docker image: [e.g., ghcr.io/alexta69/metube:latest]
|
|
||||||
- Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
|
|
||||||
|
|
||||||
Error Messages or Issues:
|
|
||||||
[Paste any error messages, logs, or unexpected behavior here]
|
|
||||||
|
|
||||||
Other relevant information:
|
|
||||||
[Screenshots, examples, etc.]
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
@@ -1,30 +1,13 @@
|
|||||||
name: 💬 General Discussion
|
|
||||||
description: Start a general discussion about MeTube
|
|
||||||
title: "[Discussion]: "
|
|
||||||
labels: ["discussion"]
|
|
||||||
assignees: []
|
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
|
||||||
id: prerequisites
|
|
||||||
attributes:
|
|
||||||
label: Prerequisites
|
|
||||||
description: Please confirm you have completed these steps before starting a discussion
|
|
||||||
options:
|
|
||||||
- label: I have searched existing discussions to ensure this topic hasn't been discussed before
|
|
||||||
required: true
|
|
||||||
- label: I have read the [README](https://github.com/alexta69/metube#readme) and relevant sections
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## Discussion Guidelines
|
## Discussion Guidelines
|
||||||
|
|
||||||
This is for general discussions about MeTube. For specific issues, please use:
|
This is for general discussions about MeTube. For specific topics, better homes exist:
|
||||||
- **Bug reports** → Use the Bug Report issue template
|
- **Bug reports** → [open an issue](https://github.com/alexta69/metube/issues/new?template=bug_report.yml)
|
||||||
- **Feature requests** → Use the Feature Request issue template
|
- **Feature requests** → [open an issue](https://github.com/alexta69/metube/issues/new?template=feature_request.yml) or post in [Ideas](https://github.com/alexta69/metube/discussions/categories/ideas)
|
||||||
- **Questions** → Use the Question issue template
|
- **Questions** → post in [Q&A](https://github.com/alexta69/metube/discussions/categories/q-a)
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: discussion-topic
|
id: discussion-topic
|
||||||
@@ -34,12 +17,3 @@ body:
|
|||||||
placeholder: Please provide a clear topic for discussion
|
placeholder: Please provide a clear topic for discussion
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Any other information that might be helpful for the discussion
|
|
||||||
placeholder: Links, examples, or other relevant information
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Read this first — MeTube's scope
|
||||||
|
|
||||||
|
MeTube's scope is deliberately narrow: **it downloads well and stops once the file is written.**
|
||||||
|
Ideas that improve the download itself are welcome — usually as community PRs, since the
|
||||||
|
maintainer is unlikely to implement requests ([details](https://github.com/alexta69/metube#-submitting-feature-requests)).
|
||||||
|
Post-download file management is out of scope regardless of implementation quality.
|
||||||
|
|
||||||
|
**Already decided — please don't re-request:**
|
||||||
|
- 🔒 Built-in authentication / password → use a reverse proxy ([#931](https://github.com/alexta69/metube/issues/931), [wiki](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations))
|
||||||
|
- 🔔 Notifications on download completion → available today via the `Exec` postprocessor ([recipe](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook))
|
||||||
|
- ✏️ Renaming, converting, tagging, or organizing files after download → out of scope ([#495](https://github.com/alexta69/metube/issues/495), [#1027](https://github.com/alexta69/metube/issues/1027))
|
||||||
|
- 🎚️ Pre-download format/quality/audio-track picker → tracked in [#1032](https://github.com/alexta69/metube/issues/1032)
|
||||||
|
- 📋 Pre-download playlist item browser → tracked in [#1030](https://github.com/alexta69/metube/issues/1030)
|
||||||
|
- 🎛️ Config switches to disable individual features → declined ([#976](https://github.com/alexta69/metube/issues/976))
|
||||||
|
- ⏰ Download scheduler → declined; add items with auto-start off and start them when you like ([#838](https://github.com/alexta69/metube/issues/838))
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: Your Idea
|
||||||
|
description: Describe the idea and what problem it solves
|
||||||
|
placeholder: |
|
||||||
|
What would you like to see?
|
||||||
|
What problem does it solve?
|
||||||
|
Would you be willing to implement it as a PR?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Quick Resources
|
||||||
|
|
||||||
|
Your question may already be answered here:
|
||||||
|
- **[README](https://github.com/alexta69/metube#readme)** — complete setup and configuration guide
|
||||||
|
- **[Troubleshooting FAQ](https://github.com/alexta69/metube/wiki/Troubleshooting-FAQ)** — common problems and their fixes
|
||||||
|
- **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** — common yt-dlp configurations (notifications, metadata, audio extraction, ...)
|
||||||
|
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** — filename template examples
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: Your Question
|
||||||
|
description: What would you like to know about MeTube?
|
||||||
|
placeholder: |
|
||||||
|
What are you trying to achieve?
|
||||||
|
What's your current setup (docker-compose / environment variables)?
|
||||||
|
What have you tried so far, and what happened (errors, logs)?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
name: ❓ Q&A
|
|
||||||
description: Ask a question and get answers from the community
|
|
||||||
title: "[Q&A]: "
|
|
||||||
labels: ["q-and-a"]
|
|
||||||
assignees: []
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
id: prerequisites
|
|
||||||
attributes:
|
|
||||||
label: Prerequisites
|
|
||||||
description: Please confirm you have completed these steps before asking your question
|
|
||||||
options:
|
|
||||||
- label: I have searched existing discussions and issues to see if my question has been answered before
|
|
||||||
required: true
|
|
||||||
- label: I have read the [README](https://github.com/alexta69/metube#readme) and relevant sections
|
|
||||||
required: true
|
|
||||||
- label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## Quick Resources
|
|
||||||
|
|
||||||
Before asking your question, please check these resources:
|
|
||||||
- **[README](https://github.com/alexta69/metube#readme)** - Complete setup and configuration guide
|
|
||||||
- **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
|
|
||||||
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: question
|
|
||||||
attributes:
|
|
||||||
label: Your Question
|
|
||||||
description: What would you like to know about MeTube?
|
|
||||||
placeholder: |
|
|
||||||
What are you trying to achieve?
|
|
||||||
What's your current setup?
|
|
||||||
What have you tried so far?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Any other information that might be helpful (configuration, errors, screenshots, etc.)
|
|
||||||
placeholder: |
|
|
||||||
Configuration:
|
|
||||||
```yaml
|
|
||||||
# Your docker-compose.yml or environment variables
|
|
||||||
```
|
|
||||||
|
|
||||||
Error messages or logs:
|
|
||||||
[Paste any error messages or logs here]
|
|
||||||
|
|
||||||
Other relevant information:
|
|
||||||
[Screenshots, examples, etc.]
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
@@ -9,24 +9,20 @@ body:
|
|||||||
id: prerequisites
|
id: prerequisites
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
description: Please confirm you have completed these steps before submitting your bug report
|
description: Please confirm before submitting
|
||||||
options:
|
options:
|
||||||
- label: I have searched existing issues and discussions to ensure this bug hasn't been reported before
|
- label: I have searched existing issues and discussions to ensure this bug hasn't been reported before
|
||||||
required: true
|
required: true
|
||||||
- label: I have read the [troubleshooting section](https://github.com/alexta69/metube#-troubleshooting-and-submitting-issues) in the README
|
- label: If the download itself fails, I have tested the same URL and options with yt-dlp directly (see the note below) — or this bug is not download-related
|
||||||
required: true
|
|
||||||
- label: I have tested this issue with yt-dlp directly (not just through MeTube UI) as described in the README
|
|
||||||
required: true
|
|
||||||
- label: I have checked that this is not a yt-dlp issue (if it is, please report it to [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues) instead)
|
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## Important Notes
|
## Important Notes
|
||||||
- **MeTube is only a UI for yt-dlp** - issues with authentication, postprocessing, permissions, or other yt-dlp functionality should be reported to the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues)
|
- **MeTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp)** — issues with authentication, postprocessing, site support, or other download functionality should be reported to the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues)
|
||||||
- Before reporting, please test with yt-dlp directly using: `docker exec -ti metube sh` then `cd /downloads` and run yt-dlp commands
|
- To test with yt-dlp directly: `docker exec -ti metube sh`, then `cd /downloads` and run `yt-dlp` with your URL and options
|
||||||
- If yt-dlp works directly but MeTube doesn't, then it's a MeTube issue
|
- If yt-dlp works directly but MeTube doesn't, then it's a MeTube issue — report it here
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: bug-description
|
id: bug-description
|
||||||
@@ -47,10 +43,13 @@ body:
|
|||||||
id: ytdl-test-results
|
id: ytdl-test-results
|
||||||
attributes:
|
attributes:
|
||||||
label: yt-dlp Direct Test Results
|
label: yt-dlp Direct Test Results
|
||||||
description: Results of testing the same URL/configuration directly with yt-dlp (required)
|
description: >-
|
||||||
|
If the download itself fails: paste the exact yt-dlp command you ran AND its output —
|
||||||
|
download-failure reports without this will be closed as needs-info.
|
||||||
|
If this is a UI or app bug that doesn't involve a failing download, just write "UI bug".
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Command used: yt-dlp [your-command-here]
|
Command used: yt-dlp [your-command-here]
|
||||||
Result: [success/error and output]
|
Result: [paste the output here]
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -64,12 +63,12 @@ body:
|
|||||||
- MeTube version: [e.g., latest, specific version]
|
- MeTube version: [e.g., latest, specific version]
|
||||||
- Docker image: [e.g., ghcr.io/alexta69/metube:latest]
|
- Docker image: [e.g., ghcr.io/alexta69/metube:latest]
|
||||||
- Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
|
- Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
```yaml
|
```yaml
|
||||||
# Your docker-compose.yml or environment variables
|
# Your docker-compose.yml or environment variables
|
||||||
```
|
```
|
||||||
|
|
||||||
Logs:
|
Logs:
|
||||||
```bash
|
```bash
|
||||||
docker logs metube
|
docker logs metube
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: MeTube Community Discussions
|
- name: ❓ Questions & Support
|
||||||
url: https://github.com/alexta69/metube/discussions
|
url: https://github.com/alexta69/metube/discussions/categories/q-a
|
||||||
about: Ask questions and discuss MeTube with the community
|
about: Ask usage and configuration questions in Discussions Q&A — issues are for bugs and feature requests
|
||||||
- name: yt-dlp Issues
|
- name: 📖 Troubleshooting FAQ
|
||||||
|
url: https://github.com/alexta69/metube/wiki/Troubleshooting-FAQ
|
||||||
|
about: Common problems and their fixes
|
||||||
|
- name: ⬇️ yt-dlp Issues
|
||||||
url: https://github.com/yt-dlp/yt-dlp/issues
|
url: https://github.com/yt-dlp/yt-dlp/issues
|
||||||
about: Report issues related to video downloading, authentication, or site support
|
about: Report issues related to video downloading, authentication, or site support
|
||||||
|
|||||||
@@ -5,28 +5,35 @@ labels: ["enhancement"]
|
|||||||
assignees: []
|
assignees: []
|
||||||
|
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Read this first — MeTube's scope
|
||||||
|
|
||||||
|
MeTube's scope is deliberately narrow: **it downloads well and stops once the file is written.**
|
||||||
|
Features that improve the download itself are welcome — usually as community PRs, since the
|
||||||
|
maintainer is unlikely to implement requests ([details](https://github.com/alexta69/metube#-submitting-feature-requests)).
|
||||||
|
Post-download file management is out of scope regardless of implementation quality.
|
||||||
|
|
||||||
|
**Already decided — please don't re-request:**
|
||||||
|
- 🔒 Built-in authentication / password → use a reverse proxy ([#931](https://github.com/alexta69/metube/issues/931), [wiki](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations))
|
||||||
|
- 🔔 Notifications on download completion → available today via the `Exec` postprocessor ([recipe](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook))
|
||||||
|
- ✏️ Renaming, converting, tagging, or organizing files after download → out of scope ([#495](https://github.com/alexta69/metube/issues/495), [#1027](https://github.com/alexta69/metube/issues/1027))
|
||||||
|
- 🎚️ Pre-download format/quality/audio-track picker → tracked in [#1032](https://github.com/alexta69/metube/issues/1032)
|
||||||
|
- 📋 Pre-download playlist item browser → tracked in [#1030](https://github.com/alexta69/metube/issues/1030)
|
||||||
|
- 🎛️ Config switches to disable individual features → declined ([#976](https://github.com/alexta69/metube/issues/976))
|
||||||
|
- ⏰ Download scheduler → declined; add items with auto-start off and start them when you like ([#838](https://github.com/alexta69/metube/issues/838))
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: prerequisites
|
id: prerequisites
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
description: Please confirm you have completed these steps before submitting your feature request
|
description: Please confirm before submitting
|
||||||
options:
|
options:
|
||||||
- label: I have searched existing issues and discussions to ensure this feature hasn't been requested before
|
- label: I have searched existing issues and checked the "Already decided" list above
|
||||||
required: true
|
required: true
|
||||||
- label: I have read the [feature request guidelines](https://github.com/alexta69/metube#-submitting-feature-requests) in the README
|
- label: I have considered if this feature belongs in yt-dlp instead (downloading, processing, site support) — if so, please request it in the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues)
|
||||||
required: true
|
required: true
|
||||||
- label: I understand that MeTube development relies on community contributions and the maintainer is not likely to implement this feature
|
|
||||||
required: true
|
|
||||||
- label: I have considered if this feature should be implemented in yt-dlp instead of MeTube (if so, please report to [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues))
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## Important Notes
|
|
||||||
- **MeTube development relies on code contributions by the community** - the project is feature-complete for the maintainer's use cases
|
|
||||||
- **Consider if this belongs in yt-dlp** - if it's related to video downloading, processing, or site support, it might belong in the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues) instead
|
|
||||||
- **Some features may not be accepted** - in an effort to reduce bloat, some PRs may not be accepted
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature-description
|
id: feature-description
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
name: ❓ Question
|
|
||||||
description: Ask a question about MeTube usage, configuration, or general help
|
|
||||||
title: "[Question]: "
|
|
||||||
labels: ["question"]
|
|
||||||
assignees: []
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
id: prerequisites
|
|
||||||
attributes:
|
|
||||||
label: Prerequisites
|
|
||||||
description: Please confirm you have completed these steps before asking your question
|
|
||||||
options:
|
|
||||||
- label: I have searched existing issues and discussions to see if my question has been answered before
|
|
||||||
required: true
|
|
||||||
- label: I have read the [README](https://github.com/alexta69/metube#readme) and relevant sections
|
|
||||||
required: true
|
|
||||||
- label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
|
|
||||||
required: true
|
|
||||||
- label: I have read the [troubleshooting section](https://github.com/alexta69/metube#-troubleshooting-and-submitting-issues) if this is a technical issue
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## Quick Resources
|
|
||||||
|
|
||||||
Before asking your question, please check these resources:
|
|
||||||
- **[README](https://github.com/alexta69/metube#readme)** - Complete setup and configuration guide
|
|
||||||
- **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
|
|
||||||
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: question
|
|
||||||
attributes:
|
|
||||||
label: Your Question
|
|
||||||
description: What would you like to know about MeTube?
|
|
||||||
placeholder: |
|
|
||||||
What are you trying to achieve?
|
|
||||||
What's your current setup?
|
|
||||||
What have you tried so far?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Any other information that might be helpful (configuration, errors, screenshots, etc.)
|
|
||||||
placeholder: |
|
|
||||||
Configuration:
|
|
||||||
```yaml
|
|
||||||
# Your docker-compose.yml or environment variables
|
|
||||||
```
|
|
||||||
|
|
||||||
Error messages or logs:
|
|
||||||
[Paste any error messages or logs here]
|
|
||||||
|
|
||||||
Other relevant information:
|
|
||||||
[Screenshots, examples, etc.]
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
+33
-47
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
- name: Enable pnpm
|
- name: Enable pnpm
|
||||||
@@ -117,25 +117,27 @@ jobs:
|
|||||||
- name: Get current date
|
- name: Get current date
|
||||||
id: date
|
id: date
|
||||||
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Get commits since last release
|
- name: Get commits since last release
|
||||||
id: commits
|
id: commits
|
||||||
|
env:
|
||||||
|
DATE: ${{ steps.date.outputs.date }}
|
||||||
run: |
|
run: |
|
||||||
# Fetch all tags
|
|
||||||
git fetch --tags
|
git fetch --tags
|
||||||
|
|
||||||
# Get the last tag (sorted by version, using date format YYYY.MM.DD)
|
# Exclude today's tag: on a same-day rerun the notes must cover the
|
||||||
LAST_TAG=$(git tag -l --sort=-version:refname | grep -E '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$' | head -n 1)
|
# whole day, not just the commits since the morning release.
|
||||||
|
LAST_TAG=$(git tag -l --sort=-version:refname | grep -E '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$' | grep -v "^${DATE}$" | head -n 1)
|
||||||
|
|
||||||
if [ -z "$LAST_TAG" ]; then
|
if [ -z "$LAST_TAG" ]; then
|
||||||
# No previous release, skip commits for first release
|
|
||||||
COMMITS=""
|
COMMITS=""
|
||||||
echo "has_commits=false" >> $GITHUB_OUTPUT
|
echo "has_commits=false" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
# Get commits since last tag
|
|
||||||
COMMITS=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
|
COMMITS=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
|
||||||
if [ -z "$COMMITS" ]; then
|
if [ -z "$COMMITS" ]; then
|
||||||
echo "has_commits=false" >> $GITHUB_OUTPUT
|
echo "has_commits=false" >> $GITHUB_OUTPUT
|
||||||
@@ -143,18 +145,13 @@ jobs:
|
|||||||
echo "has_commits=true" >> $GITHUB_OUTPUT
|
echo "has_commits=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Escape for use in YAML/multiline output
|
|
||||||
{
|
{
|
||||||
echo 'commits<<EOF'
|
echo 'commits<<EOF'
|
||||||
echo "$COMMITS"
|
echo "$COMMITS"
|
||||||
echo EOF
|
echo EOF
|
||||||
} >> $GITHUB_OUTPUT
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Also output for debugging
|
|
||||||
echo "Last tag: ${LAST_TAG:-none}"
|
|
||||||
echo "Commits since last release:"
|
|
||||||
echo "$COMMITS"
|
|
||||||
- name: Generate release body
|
- name: Generate release body
|
||||||
id: release_body
|
id: release_body
|
||||||
env:
|
env:
|
||||||
@@ -176,7 +173,7 @@ jobs:
|
|||||||
echo '**GitHub Container Registry:**'
|
echo '**GitHub Container Registry:**'
|
||||||
echo "- \`${GHCR_REPO}:latest\`"
|
echo "- \`${GHCR_REPO}:latest\`"
|
||||||
echo "- \`${GHCR_REPO}:${DATE}\`"
|
echo "- \`${GHCR_REPO}:${DATE}\`"
|
||||||
|
|
||||||
if [ "$HAS_COMMITS" = "true" ] && [ -n "$COMMITS" ]; then
|
if [ "$HAS_COMMITS" = "true" ] && [ -n "$COMMITS" ]; then
|
||||||
echo ''
|
echo ''
|
||||||
echo '## Changes'
|
echo '## Changes'
|
||||||
@@ -184,39 +181,28 @@ jobs:
|
|||||||
echo "$COMMITS"
|
echo "$COMMITS"
|
||||||
fi
|
fi
|
||||||
} > release_body.txt
|
} > release_body.txt
|
||||||
|
|
||||||
{
|
- name: Create or update GitHub Release (mark as latest)
|
||||||
echo 'body<<EOF'
|
|
||||||
cat release_body.txt
|
|
||||||
echo EOF
|
|
||||||
} >> $GITHUB_OUTPUT
|
|
||||||
- name: Delete existing release if present
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG_NAME: ${{ steps.date.outputs.date }}
|
TAG_NAME: ${{ steps.date.outputs.date }}
|
||||||
run: |
|
run: |
|
||||||
# Check if release exists and delete it
|
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
|
||||||
if gh release view "$TAG_NAME" &>/dev/null; then
|
echo "Release $TAG_NAME exists; updating."
|
||||||
echo "Release $TAG_NAME already exists, deleting it..."
|
# Force-move the tag in place so it matches the rebuilt Docker
|
||||||
gh release delete "$TAG_NAME" --yes || true
|
# image. Never delete+recreate the tag: that corrupts GitHub's
|
||||||
|
# release index (broke release 2026.07.05).
|
||||||
|
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG_NAME}" \
|
||||||
|
-f sha="$GITHUB_SHA" -F force=true
|
||||||
|
gh release edit "$TAG_NAME" \
|
||||||
|
--title "Release $TAG_NAME" \
|
||||||
|
--notes-file release_body.txt \
|
||||||
|
--latest
|
||||||
|
else
|
||||||
|
echo "Release $TAG_NAME does not exist; creating."
|
||||||
|
gh release create "$TAG_NAME" \
|
||||||
|
--target "$GITHUB_SHA" \
|
||||||
|
--title "Release $TAG_NAME" \
|
||||||
|
--notes-file release_body.txt \
|
||||||
|
--latest
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fetch tags to check remote
|
|
||||||
git fetch --tags
|
|
||||||
|
|
||||||
# Check if tag exists (locally or remotely) and delete it
|
|
||||||
if git rev-parse "$TAG_NAME" &>/dev/null 2>&1 || git ls-remote --tags origin "$TAG_NAME" | grep -q "$TAG_NAME"; then
|
|
||||||
echo "Tag $TAG_NAME already exists, deleting it..."
|
|
||||||
git tag -d "$TAG_NAME" 2>/dev/null || true
|
|
||||||
git push origin ":refs/tags/$TAG_NAME" || true
|
|
||||||
fi
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v3
|
|
||||||
with:
|
|
||||||
tag_name: ${{ steps.date.outputs.date }}
|
|
||||||
name: Release ${{ steps.date.outputs.date }}
|
|
||||||
body_path: release_body.txt
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
token: ${{ secrets.AUTOUPDATE_PAT }}
|
token: ${{ secrets.AUTOUPDATE_PAT }}
|
||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -1,5 +1,53 @@
|
|||||||
# Agent Guidelines
|
# 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
|
## README.md size constraint
|
||||||
|
|
||||||
The README.md is synced to Docker Hub, which has a **25,000 character limit**.
|
The README.md is synced to Docker Hub, which has a **25,000 character limit**.
|
||||||
@@ -30,7 +78,41 @@ uv run pytest app/tests/
|
|||||||
|
|
||||||
All of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.
|
All of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.
|
||||||
|
|
||||||
## Code style
|
Gotchas:
|
||||||
|
|
||||||
|
- Backend tests must run **from the repo root**: `main.py` resolves the static-assets
|
||||||
|
path relative to the cwd, and several test modules import `main`. Running from
|
||||||
|
`app/` makes five test modules fail to import.
|
||||||
|
- The frontend must be **built before** running backend tests (same reason — the
|
||||||
|
assets at `ui/dist/metube/browser` must exist). The command order above is
|
||||||
|
load-bearing.
|
||||||
|
- `app/tests/test_ytdl_utils.py` stubs `yt_dlp` at import time. Run standalone,
|
||||||
|
two tests fail with `AttributeError: <module 'yt_dlp'> does not have the
|
||||||
|
attribute 'YoutubeDL'`; under the full suite the real module is imported first
|
||||||
|
and they pass. This is a known quirk, not a bug to fix in the code under test.
|
||||||
|
|
||||||
|
Every non-markdown push to master builds multi-arch Docker images and cuts a dated
|
||||||
|
release the same day. **Master is continuously released** — a PR must be
|
||||||
|
release-ready exactly as merged; there is no stabilization window for follow-up
|
||||||
|
fixes.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
A commit that resolves an issue must close it, with a GitHub closing keyword in
|
||||||
|
parentheses at the end of the subject line:
|
||||||
|
|
||||||
|
```
|
||||||
|
fix: stop metadata probes from writing playlist sidecar files (closes #1040)
|
||||||
|
```
|
||||||
|
|
||||||
|
Because master is the default branch and is released on every push, the issue
|
||||||
|
closes at the moment the fix ships, and keeps a permanent link to the commit that
|
||||||
|
fixed it. A bare `(#1040)` is only a reference — and reads as a pull-request
|
||||||
|
number — so it does not count; the keyword is what closes the issue.
|
||||||
|
|
||||||
|
Auto-closing leaves only a commit stub on the issue, which is not an answer to
|
||||||
|
whoever reported it. Post an explanatory comment as well: what the cause was, what
|
||||||
|
changed, and anything the reporter needs to do differently.
|
||||||
|
|
||||||
Follow `.editorconfig`:
|
Follow `.editorconfig`:
|
||||||
- Python: 4-space indent
|
- Python: 4-space indent
|
||||||
@@ -56,5 +138,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.
|
- 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.
|
- 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 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`.
|
- 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.
|
- 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
|
WORKDIR /metube
|
||||||
COPY ui ./
|
COPY ui ./
|
||||||
@@ -66,7 +70,8 @@ ENV TEMP_DIR=/downloads
|
|||||||
ENV PORT=8081
|
ENV PORT=8081
|
||||||
VOLUME /downloads
|
VOLUME /downloads
|
||||||
EXPOSE 8081
|
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
|
# Add build-time argument for version
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
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:
|
Key capabilities:
|
||||||
* Download videos, audio, captions, and thumbnails from a browser UI.
|
* Download videos, audio, captions, and thumbnails from a browser UI.
|
||||||
* Download playlists and channels, with configurable output and download options.
|
* 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 🐳 Run using Docker
|
## 🐳 Run using Docker
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ Key capabilities:
|
|||||||
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
|
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐳 Run using docker-compose
|
## 🐳 Run using Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -34,11 +34,20 @@ services:
|
|||||||
|
|
||||||
## ⚙️ Configuration via environment variables
|
## ⚙️ 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
|
### ⬇️ Download Behavior
|
||||||
|
|
||||||
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
||||||
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
|
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
|
||||||
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
||||||
* __SUBSCRIPTION_DEFAULT_CHECK_INTERVAL__: Default minutes between automatic checks for each subscription. Defaults to `60`.
|
* __SUBSCRIPTION_DEFAULT_CHECK_INTERVAL__: Default minutes between automatic checks for each subscription. Defaults to `60`.
|
||||||
@@ -53,6 +62,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`.
|
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`.
|
||||||
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
||||||
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
|
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
|
||||||
|
* __DEFAULT_FOLDER__: Custom directory to pre-select in the download folder field, relative to __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__), for when most downloads go to the same place. It is only a starting value — the field stays editable, so any other folder can still be picked per download. Requires __CUSTOM_DIRS__; ignored with a warning otherwise. Defaults to empty, i.e. the base download directory.
|
||||||
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
|
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
|
||||||
* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
|
* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
|
||||||
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
|
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
|
||||||
@@ -71,8 +81,13 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
* __YTDL_OPTIONS_PRESETS__: Named bundles of yt-dlp options, selectable per download in the UI. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for format and examples.
|
* __YTDL_OPTIONS_PRESETS__: Named bundles of yt-dlp options, selectable per download in the UI. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for format and examples.
|
||||||
* __YTDL_OPTIONS_PRESETS_FILE__: Path to a JSON file containing presets. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options).
|
* __YTDL_OPTIONS_PRESETS_FILE__: Path to a JSON file containing presets. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options).
|
||||||
* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show a free-text field in the UI for per-download yt-dlp option overrides. Defaults to `false`. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details and security considerations.
|
* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show a free-text field in the UI for per-download yt-dlp option overrides. Defaults to `false`. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details and security considerations.
|
||||||
|
* __ALLOW_PRIVATE_ADDRESSES__: Whether to allow downloads from private, loopback, link-local and other non-global addresses. Defaults to `false`, which protects against SSRF by refusing URLs that resolve to internal hosts. Set to `true` only in trusted environments — for example when routing traffic through a proxy/VPN client in Fake-IP mode (sing-box, Clash, Mihomo), which resolves hosts to the `198.18.0.0/15` range. Enabling this disables the SSRF protection entirely, so only use it when you control the network. You do **not** need this to use a proxy on an internal address: a proxy configured through the `proxy` option in `YTDL_OPTIONS` (or the `*_proxy` environment variables) is always reachable at its own host and port, wherever it lives.
|
||||||
* __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled).
|
* __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled).
|
||||||
|
|
||||||
|
A filename that would exceed the limit the filesystem accepts is shortened to fit, keeping its extension, with room left for the suffixes yt-dlp adds while downloading. Sites that put a long description in the title would otherwise fail the download outright with `File name too long`. Use `trim_file_name` in `YTDL_OPTIONS` if you want names shorter than the filesystem's own limit, or `restrictfilenames` to strip non-ASCII characters.
|
||||||
|
|
||||||
|
Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a feed-level `.info.json` and thumbnail when you add a playlist or channel. These reuse the template of the items they belong to — `OUTPUT_TEMPLATE_CHANNEL` or `OUTPUT_TEMPLATE_PLAYLIST` — evaluated against the feed itself, so with the defaults they land in the same folder as the videos, named after the feed. Set `allow_playlist_files` to `false` in `YTDL_OPTIONS` to skip them.
|
||||||
|
|
||||||
### 🌐 Web Server & URLs
|
### 🌐 Web Server & URLs
|
||||||
|
|
||||||
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
|
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
|
||||||
@@ -83,18 +98,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`.
|
* __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
|
||||||
* __CERTFILE__: HTTPS certificate file path.
|
* __CERTFILE__: HTTPS certificate file path.
|
||||||
* __KEYFILE__: HTTPS key 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.
|
* __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
|
## 🎛️ 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:
|
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 +235,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.
|
* After upload, the cookie indicator should show as active.
|
||||||
* Use **Delete Cookies** in the same section to remove uploaded cookies.
|
* 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.
|
* [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.
|
||||||
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`.
|
* [Lidarr](https://lidarr.audio) — full music library manager; add the folder as an import path.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 🔒 HTTPS support, and running behind a reverse proxy
|
## 🔒 HTTPS support, and running behind a reverse proxy
|
||||||
|
|
||||||
@@ -293,11 +279,7 @@ services:
|
|||||||
- KEYFILE=/ssl/key.pem
|
- KEYFILE=/ssl/key.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
MeTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly.
|
MeTube 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:
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
location /metube/ {
|
location /metube/ {
|
||||||
@@ -309,41 +291,7 @@ location /metube/ {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: the extra `proxy_set_header` directives are there to make WebSocket work.
|
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).
|
||||||
|
|
||||||
### 🌐 Apache
|
|
||||||
|
|
||||||
Contributed by [PIE-yt](https://github.com/PIE-yt). Source [here](https://gist.github.com/PIE-yt/29e7116588379032427f5bd446b2cac4).
|
|
||||||
|
|
||||||
```apache
|
|
||||||
# For putting in your Apache sites site.conf
|
|
||||||
# Serves MeTube under a /metube/ subdir (http://yourdomain.com/metube/)
|
|
||||||
<Location /metube/>
|
|
||||||
ProxyPass http://localhost:8081/ retry=0 timeout=30
|
|
||||||
ProxyPassReverse http://localhost:8081/
|
|
||||||
</Location>
|
|
||||||
|
|
||||||
<Location /metube/socket.io>
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteCond %{QUERY_STRING} transport=websocket [NC]
|
|
||||||
RewriteRule /(.*) ws://localhost:8081/socket.io/$1 [P,L]
|
|
||||||
ProxyPass http://localhost:8081/socket.io retry=0 timeout=30
|
|
||||||
ProxyPassReverse http://localhost:8081/socket.io
|
|
||||||
</Location>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🌐 Caddy
|
|
||||||
|
|
||||||
The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com).
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
example.com {
|
|
||||||
route /metube/* {
|
|
||||||
uri strip_prefix metube
|
|
||||||
reverse_proxy metube:8081
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Updating yt-dlp
|
## 🔄 Updating yt-dlp
|
||||||
|
|
||||||
@@ -358,9 +306,11 @@ docker exec -ti metube sh
|
|||||||
cd /downloads
|
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
|
## 💡 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
|
## 🛠️ 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")
|
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
|
||||||
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
|
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 = {
|
CODEC_FILTER_MAP = {
|
||||||
'h264': "[vcodec~='^(h264|avc)']",
|
'h264': "[vcodec~='^(h264|avc)']",
|
||||||
'h265': "[vcodec~='^(h265|hevc)']",
|
'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()
|
quality = (quality or "best").strip().lower()
|
||||||
|
|
||||||
if format.startswith("custom:"):
|
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:]
|
return format[7:]
|
||||||
|
|
||||||
if download_type == "thumbnail":
|
if download_type == "thumbnail":
|
||||||
@@ -137,7 +156,20 @@ def get_opts(
|
|||||||
requested_subtitle_format = (format or "srt").lower()
|
requested_subtitle_format = (format or "srt").lower()
|
||||||
if requested_subtitle_format == "txt":
|
if requested_subtitle_format == "txt":
|
||||||
requested_subtitle_format = "srt"
|
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":
|
if mode == "manual_only":
|
||||||
opts["writesubtitles"] = True
|
opts["writesubtitles"] = True
|
||||||
opts["writeautomaticsub"] = False
|
opts["writeautomaticsub"] = False
|
||||||
|
|||||||
+104
-26
@@ -16,9 +16,11 @@ import logging
|
|||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
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
|
from watchfiles import DefaultFilter, Change, awatch
|
||||||
|
|
||||||
|
import bg_tasks
|
||||||
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||||
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo, coerce_optional_bool
|
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo, coerce_optional_bool
|
||||||
from yt_dlp.version import __version__ as yt_dlp_version
|
from yt_dlp.version import __version__ as yt_dlp_version
|
||||||
@@ -60,6 +62,7 @@ class Config:
|
|||||||
'CUSTOM_DIRS': 'true',
|
'CUSTOM_DIRS': 'true',
|
||||||
'CREATE_CUSTOM_DIRS': 'true',
|
'CREATE_CUSTOM_DIRS': 'true',
|
||||||
'CUSTOM_DIRS_EXCLUDE_REGEX': r'(^|/)[.@].*$',
|
'CUSTOM_DIRS_EXCLUDE_REGEX': r'(^|/)[.@].*$',
|
||||||
|
'DEFAULT_FOLDER': '',
|
||||||
'DELETE_FILE_ON_TRASHCAN': 'false',
|
'DELETE_FILE_ON_TRASHCAN': 'false',
|
||||||
'STATE_DIR': '.',
|
'STATE_DIR': '.',
|
||||||
'URL_PREFIX': '',
|
'URL_PREFIX': '',
|
||||||
@@ -79,6 +82,7 @@ class Config:
|
|||||||
'YTDL_OPTIONS_PRESETS': '{}',
|
'YTDL_OPTIONS_PRESETS': '{}',
|
||||||
'YTDL_OPTIONS_PRESETS_FILE': '',
|
'YTDL_OPTIONS_PRESETS_FILE': '',
|
||||||
'ALLOW_YTDL_OPTIONS_OVERRIDES': 'false',
|
'ALLOW_YTDL_OPTIONS_OVERRIDES': 'false',
|
||||||
|
'ALLOW_PRIVATE_ADDRESSES': 'false',
|
||||||
'CORS_ALLOWED_ORIGINS': '',
|
'CORS_ALLOWED_ORIGINS': '',
|
||||||
'ROBOTS_TXT': '',
|
'ROBOTS_TXT': '',
|
||||||
'HOST': '0.0.0.0',
|
'HOST': '0.0.0.0',
|
||||||
@@ -94,7 +98,7 @@ class Config:
|
|||||||
'YTDL_NIGHTLY_UPDATE_TIME': '',
|
'YTDL_NIGHTLY_UPDATE_TIME': '',
|
||||||
}
|
}
|
||||||
|
|
||||||
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES')
|
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES', 'ALLOW_PRIVATE_ADDRESSES')
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
for k, v in self._DEFAULTS.items():
|
for k, v in self._DEFAULTS.items():
|
||||||
@@ -124,6 +128,18 @@ class Config:
|
|||||||
if val and not val.endswith('/'):
|
if val and not val.endswith('/'):
|
||||||
setattr(self, attr, val + '/')
|
setattr(self, attr, val + '/')
|
||||||
|
|
||||||
|
# DEFAULT_FOLDER only pre-fills the form's folder field, which the UI
|
||||||
|
# does not even show without CUSTOM_DIRS. Sending one anyway would fail
|
||||||
|
# every download on the server's own folder check, so drop it and say so
|
||||||
|
# rather than leaving the user with a form that cannot submit.
|
||||||
|
self.DEFAULT_FOLDER = self.DEFAULT_FOLDER.strip().strip('/')
|
||||||
|
if self.DEFAULT_FOLDER and not self.CUSTOM_DIRS:
|
||||||
|
log.warning(
|
||||||
|
'Ignoring DEFAULT_FOLDER "%s" because CUSTOM_DIRS is not enabled',
|
||||||
|
self.DEFAULT_FOLDER,
|
||||||
|
)
|
||||||
|
self.DEFAULT_FOLDER = ''
|
||||||
|
|
||||||
# Convert relative addresses to absolute addresses to prevent the failure of file address comparison
|
# Convert relative addresses to absolute addresses to prevent the failure of file address comparison
|
||||||
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
||||||
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
||||||
@@ -140,6 +156,10 @@ class Config:
|
|||||||
self._validate_int('MAX_CONCURRENT_DOWNLOADS', minimum=1)
|
self._validate_int('MAX_CONCURRENT_DOWNLOADS', minimum=1)
|
||||||
self._validate_int('PORT', minimum=1, maximum=65535)
|
self._validate_int('PORT', minimum=1, maximum=65535)
|
||||||
self._validate_int('CLEAR_COMPLETED_AFTER', minimum=0)
|
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 = {}
|
self._runtime_overrides = {}
|
||||||
|
|
||||||
@@ -180,6 +200,7 @@ class Config:
|
|||||||
_FRONTEND_KEYS = (
|
_FRONTEND_KEYS = (
|
||||||
'CUSTOM_DIRS',
|
'CUSTOM_DIRS',
|
||||||
'CREATE_CUSTOM_DIRS',
|
'CREATE_CUSTOM_DIRS',
|
||||||
|
'DEFAULT_FOLDER',
|
||||||
'OUTPUT_TEMPLATE_CHAPTER',
|
'OUTPUT_TEMPLATE_CHAPTER',
|
||||||
'PUBLIC_HOST_URL',
|
'PUBLIC_HOST_URL',
|
||||||
'PUBLIC_HOST_AUDIO_URL',
|
'PUBLIC_HOST_AUDIO_URL',
|
||||||
@@ -301,7 +322,10 @@ async def state_dir_guard(request, handler):
|
|||||||
(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR),
|
(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR),
|
||||||
):
|
):
|
||||||
if request.path.startswith(prefix):
|
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))
|
target = os.path.realpath(os.path.join(base, rel))
|
||||||
if _is_within_state_dir(target):
|
if _is_within_state_dir(target):
|
||||||
raise web.HTTPNotFound()
|
raise web.HTTPNotFound()
|
||||||
@@ -425,12 +449,20 @@ def _clip_field_provided_in_post(raw) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_t_query_from_url(url: str) -> tuple[str, float | None]:
|
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:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
except Exception:
|
except Exception:
|
||||||
return url, None
|
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')
|
t_values = params.get('t')
|
||||||
if not t_values:
|
if not t_values:
|
||||||
return url, None
|
return url, None
|
||||||
@@ -552,6 +584,7 @@ async def _download_queue_startup(app):
|
|||||||
|
|
||||||
|
|
||||||
async def _shutdown_download_manager(app):
|
async def _shutdown_download_manager(app):
|
||||||
|
dqueue.close()
|
||||||
Download.shutdown_manager()
|
Download.shutdown_manager()
|
||||||
|
|
||||||
|
|
||||||
@@ -607,7 +640,7 @@ async def _schedule_nightly_update() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _start_nightly_update_schedule(app):
|
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)
|
app.on_startup.append(_start_nightly_update_schedule)
|
||||||
@@ -656,7 +689,7 @@ async def watch_files():
|
|||||||
await sio.emit('ytdl_options_changed', serializer.encode(result))
|
await sio.emit('ytdl_options_changed', serializer.encode(result))
|
||||||
|
|
||||||
log.info(f'Starting Watch File: {config.YTDL_OPTIONS_FILE}')
|
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):
|
async def _watch_files_startup(app):
|
||||||
await watch_files()
|
await watch_files()
|
||||||
@@ -692,6 +725,7 @@ def parse_download_options(post: dict) -> dict:
|
|||||||
playlist_item_limit = post.get('playlist_item_limit')
|
playlist_item_limit = post.get('playlist_item_limit')
|
||||||
auto_start = post.get('auto_start')
|
auto_start = post.get('auto_start')
|
||||||
split_by_chapters = post.get('split_by_chapters')
|
split_by_chapters = post.get('split_by_chapters')
|
||||||
|
sponsorblock = bool(post.get('sponsorblock'))
|
||||||
chapter_template = post.get('chapter_template')
|
chapter_template = post.get('chapter_template')
|
||||||
subtitle_language = post.get('subtitle_language')
|
subtitle_language = post.get('subtitle_language')
|
||||||
subtitle_mode = post.get('subtitle_mode')
|
subtitle_mode = post.get('subtitle_mode')
|
||||||
@@ -699,8 +733,6 @@ def parse_download_options(post: dict) -> dict:
|
|||||||
|
|
||||||
if custom_name_prefix is None:
|
if custom_name_prefix is None:
|
||||||
custom_name_prefix = ''
|
custom_name_prefix = ''
|
||||||
if custom_name_prefix and ('..' in custom_name_prefix or custom_name_prefix.startswith('/') or custom_name_prefix.startswith('\\')):
|
|
||||||
raise web.HTTPBadRequest(reason='custom_name_prefix must not contain ".." or start with a path separator')
|
|
||||||
if auto_start is None:
|
if auto_start is None:
|
||||||
auto_start = True
|
auto_start = True
|
||||||
if playlist_item_limit is None:
|
if playlist_item_limit is None:
|
||||||
@@ -725,8 +757,6 @@ def parse_download_options(post: dict) -> dict:
|
|||||||
enabled=config.ALLOW_YTDL_OPTIONS_OVERRIDES,
|
enabled=config.ALLOW_YTDL_OPTIONS_OVERRIDES,
|
||||||
)
|
)
|
||||||
|
|
||||||
if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')):
|
|
||||||
raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator')
|
|
||||||
if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language):
|
if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language):
|
||||||
raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters')
|
raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters')
|
||||||
if subtitle_mode not in VALID_SUBTITLE_MODES:
|
if subtitle_mode not in VALID_SUBTITLE_MODES:
|
||||||
@@ -816,6 +846,7 @@ def parse_download_options(post: dict) -> dict:
|
|||||||
'playlist_item_limit': playlist_item_limit,
|
'playlist_item_limit': playlist_item_limit,
|
||||||
'auto_start': auto_start,
|
'auto_start': auto_start,
|
||||||
'split_by_chapters': split_by_chapters,
|
'split_by_chapters': split_by_chapters,
|
||||||
|
'sponsorblock': sponsorblock,
|
||||||
'chapter_template': chapter_template,
|
'chapter_template': chapter_template,
|
||||||
'subtitle_language': subtitle_language,
|
'subtitle_language': subtitle_language,
|
||||||
'subtitle_mode': subtitle_mode,
|
'subtitle_mode': subtitle_mode,
|
||||||
@@ -861,6 +892,7 @@ async def add(request):
|
|||||||
o['ytdl_options_overrides'],
|
o['ytdl_options_overrides'],
|
||||||
o['clip_start'],
|
o['clip_start'],
|
||||||
o['clip_end'],
|
o['clip_end'],
|
||||||
|
sponsorblock=o['sponsorblock'],
|
||||||
)
|
)
|
||||||
return web.Response(text=serializer.encode(status))
|
return web.Response(text=serializer.encode(status))
|
||||||
|
|
||||||
@@ -878,6 +910,17 @@ async def cancel_add(request):
|
|||||||
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post(config.URL_PREFIX + 'retry')
|
||||||
|
async def retry(request):
|
||||||
|
# Singular by design, unlike the 'ids' batch endpoints: a retry re-extracts
|
||||||
|
# the URL, so it can fail per item, and the caller removes that item's done
|
||||||
|
# record only once it is confirmed re-queued. A batch form would have to
|
||||||
|
# report per-id results for the caller to know which ones to remove.
|
||||||
|
post = await _read_json_request(request)
|
||||||
|
status = await dqueue.retry(_require_id(post))
|
||||||
|
return web.Response(text=serializer.encode(status), content_type='application/json')
|
||||||
|
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||||
async def subscribe(request):
|
async def subscribe(request):
|
||||||
post = await _read_json_request(request)
|
post = await _read_json_request(request)
|
||||||
@@ -891,9 +934,6 @@ async def subscribe(request):
|
|||||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
|
raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
|
||||||
if cic < 1:
|
if cic < 1:
|
||||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1')
|
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1')
|
||||||
if o.get('clip_start') is not None or o.get('clip_end') is not None:
|
|
||||||
raise web.HTTPBadRequest(reason='clip options are not supported for subscriptions')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
skip_subscriber_only = coerce_optional_bool(
|
skip_subscriber_only = coerce_optional_bool(
|
||||||
post.get('skip_subscriber_only'),
|
post.get('skip_subscriber_only'),
|
||||||
@@ -903,6 +943,19 @@ async def subscribe(request):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise web.HTTPBadRequest(reason=str(exc)) from exc
|
raise web.HTTPBadRequest(reason=str(exc)) from exc
|
||||||
|
|
||||||
|
# A t= timestamp in the URL means "start playing here" and parse_download_options
|
||||||
|
# turns it into a clip start, which is right for a one-off download of that video.
|
||||||
|
# A subscription URL is a channel or playlist, so a timestamp left on it says
|
||||||
|
# nothing about the videos it will yield — honour clip fields only when the
|
||||||
|
# caller supplied them explicitly, rather than silently clipping every future
|
||||||
|
# download. The t= param is still stripped from the stored URL.
|
||||||
|
clip_given = (
|
||||||
|
_clip_field_provided_in_post(post.get('clip_start'))
|
||||||
|
or _clip_field_provided_in_post(post.get('clip_end'))
|
||||||
|
)
|
||||||
|
sub_clip_start = o['clip_start'] if clip_given else None
|
||||||
|
sub_clip_end = o['clip_end'] if clip_given else None
|
||||||
|
|
||||||
result = await submgr.add_subscription(
|
result = await submgr.add_subscription(
|
||||||
o['url'],
|
o['url'],
|
||||||
check_interval_minutes=cic,
|
check_interval_minutes=cic,
|
||||||
@@ -920,8 +973,11 @@ async def subscribe(request):
|
|||||||
subtitle_mode=o['subtitle_mode'],
|
subtitle_mode=o['subtitle_mode'],
|
||||||
ytdl_options_presets=o['ytdl_options_presets'],
|
ytdl_options_presets=o['ytdl_options_presets'],
|
||||||
ytdl_options_overrides=o['ytdl_options_overrides'],
|
ytdl_options_overrides=o['ytdl_options_overrides'],
|
||||||
|
sponsorblock=o['sponsorblock'],
|
||||||
title_regex=post.get('title_regex'),
|
title_regex=post.get('title_regex'),
|
||||||
skip_subscriber_only=skip_subscriber_only,
|
skip_subscriber_only=skip_subscriber_only,
|
||||||
|
clip_start=sub_clip_start,
|
||||||
|
clip_end=sub_clip_end,
|
||||||
)
|
)
|
||||||
return web.Response(text=serializer.encode(result))
|
return web.Response(text=serializer.encode(result))
|
||||||
|
|
||||||
@@ -941,7 +997,7 @@ async def subscriptions_update(request):
|
|||||||
k: v
|
k: v
|
||||||
for k, v in post.items()
|
for k, v in post.items()
|
||||||
if k != 'id'
|
if k != 'id'
|
||||||
and k in ('enabled', 'check_interval_minutes', 'name', 'title_regex', 'skip_subscriber_only')
|
and k in ('enabled', 'check_interval_minutes', 'name', 'folder', 'title_regex', 'skip_subscriber_only')
|
||||||
}
|
}
|
||||||
if not changes:
|
if not changes:
|
||||||
raise web.HTTPBadRequest(reason='no valid fields to update')
|
raise web.HTTPBadRequest(reason='no valid fields to update')
|
||||||
@@ -970,13 +1026,27 @@ async def subscriptions_check(request):
|
|||||||
result = await submgr.check_now([str(i) for i in ids] if ids else None)
|
result = await submgr.check_now([str(i) for i in ids] if ids else None)
|
||||||
return web.Response(text=serializer.encode(result))
|
return web.Response(text=serializer.encode(result))
|
||||||
|
|
||||||
|
def _require_id(post: dict) -> str:
|
||||||
|
id = post.get('id')
|
||||||
|
if not isinstance(id, str) or not id:
|
||||||
|
raise web.HTTPBadRequest(reason="'id' must be a non-empty string")
|
||||||
|
return id
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
@routes.post(config.URL_PREFIX + 'delete')
|
||||||
async def delete(request):
|
async def delete(request):
|
||||||
post = await _read_json_request(request)
|
post = await _read_json_request(request)
|
||||||
ids = post.get('ids')
|
ids = _require_id_list(post)
|
||||||
where = post.get('where')
|
where = post.get('where')
|
||||||
if not ids or where not in ['queue', 'done']:
|
if where not in ['queue', 'done']:
|
||||||
log.error("Bad request: missing 'ids' or incorrect 'where' value")
|
log.error("Bad request: incorrect 'where' value")
|
||||||
raise web.HTTPBadRequest()
|
raise web.HTTPBadRequest()
|
||||||
status = await (dqueue.cancel(ids) if where == 'queue' else dqueue.clear(ids))
|
status = await (dqueue.cancel(ids) if where == 'queue' else dqueue.clear(ids))
|
||||||
log.info(f"Download delete request processed for ids: {ids}, where: {where}")
|
log.info(f"Download delete request processed for ids: {ids}, where: {where}")
|
||||||
@@ -985,7 +1055,7 @@ async def delete(request):
|
|||||||
@routes.post(config.URL_PREFIX + 'start')
|
@routes.post(config.URL_PREFIX + 'start')
|
||||||
async def start(request):
|
async def start(request):
|
||||||
post = await _read_json_request(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}")
|
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||||
status = await dqueue.start_pending(ids)
|
status = await dqueue.start_pending(ids)
|
||||||
return web.Response(text=serializer.encode(status))
|
return web.Response(text=serializer.encode(status))
|
||||||
@@ -1065,12 +1135,15 @@ async def cookie_status(request):
|
|||||||
async def history(request):
|
async def history(request):
|
||||||
history = { 'done': [], 'queue': [], 'pending': []}
|
history = { 'done': [], 'queue': [], 'pending': []}
|
||||||
|
|
||||||
for _, v in dqueue.queue.saved_items():
|
# Served from the in-memory queues (like the socket 'all' event) rather
|
||||||
history['queue'].append(v)
|
# than saved_items(), which reloads and re-compacts the on-disk state on
|
||||||
for _, v in dqueue.done.saved_items():
|
# every call.
|
||||||
history['done'].append(v)
|
for _, v in dqueue.queue.items():
|
||||||
for _, v in dqueue.pending.saved_items():
|
history['queue'].append(v.info)
|
||||||
history['pending'].append(v)
|
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")
|
log.info("Sending download history")
|
||||||
return web.Response(text=serializer.encode(history))
|
return web.Response(text=serializer.encode(history))
|
||||||
@@ -1082,13 +1155,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('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)
|
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
|
||||||
if config.CUSTOM_DIRS:
|
if config.CUSTOM_DIRS:
|
||||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
# 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:
|
if config.YTDL_OPTIONS_FILE:
|
||||||
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
|
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
|
||||||
|
|
||||||
def get_custom_dirs():
|
def get_custom_dirs():
|
||||||
cache_ttl_seconds = 5
|
cache_ttl_seconds = 5
|
||||||
now = asyncio.get_running_loop().time()
|
now = time.monotonic()
|
||||||
cache_key = (
|
cache_key = (
|
||||||
config.DOWNLOAD_DIR,
|
config.DOWNLOAD_DIR,
|
||||||
config.AUDIO_DOWNLOAD_DIR,
|
config.AUDIO_DOWNLOAD_DIR,
|
||||||
@@ -1198,6 +1275,7 @@ async def add_cors(request):
|
|||||||
|
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'retry', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Conservative music metadata enrichment for audio downloads.
|
||||||
|
|
||||||
|
This module only consumes fields already supplied by yt-dlp or retained on
|
||||||
|
MeTube's queued playlist entry. It intentionally performs no external lookup
|
||||||
|
or site-specific album detection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from yt_dlp.postprocessor.common import PostProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def _has_value(value: Any) -> bool:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return bool(value.strip())
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return any(_has_value(item) for item in value)
|
||||||
|
return value is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: Any) -> Optional[int]:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _track_position(value: Any) -> tuple[Optional[int], Optional[int]]:
|
||||||
|
"""Return a track number and optional total from a scalar or ``n/total``."""
|
||||||
|
if isinstance(value, str) and '/' in value:
|
||||||
|
number, total = value.split('/', 1)
|
||||||
|
return _positive_int(number.strip()), _positive_int(total.strip())
|
||||||
|
return _positive_int(value), None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_positive_int(*values: Any) -> Optional[int]:
|
||||||
|
return next((number for value in values if (number := _positive_int(value))), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_album_signal(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
|
||||||
|
"""Use only extractor-owned fields to identify album-level metadata."""
|
||||||
|
return any(
|
||||||
|
_has_value(entry.get(key))
|
||||||
|
for entry in (info, source_entry)
|
||||||
|
for key in ('album', 'track_number')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_music_audio(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
|
||||||
|
return _has_album_signal(info, source_entry) or any(
|
||||||
|
_has_value(entry.get(key))
|
||||||
|
for entry in (info, source_entry)
|
||||||
|
for key in ('track', 'artists')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prefer_square_thumbnail(info: dict[str, Any]) -> None:
|
||||||
|
"""Move the largest known square thumbnail to yt-dlp's preferred slot."""
|
||||||
|
thumbnails = info.get('thumbnails')
|
||||||
|
if not isinstance(thumbnails, list) or len(thumbnails) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
candidates: list[tuple[int, int]] = []
|
||||||
|
for index, thumbnail in enumerate(thumbnails):
|
||||||
|
if not isinstance(thumbnail, dict):
|
||||||
|
continue
|
||||||
|
width = _positive_int(thumbnail.get('width'))
|
||||||
|
height = _positive_int(thumbnail.get('height'))
|
||||||
|
if width is not None and width == height:
|
||||||
|
candidates.append((width * height, index))
|
||||||
|
if not candidates:
|
||||||
|
return
|
||||||
|
|
||||||
|
_, selected_index = max(candidates)
|
||||||
|
selected = thumbnails.pop(selected_index)
|
||||||
|
thumbnails.append(selected)
|
||||||
|
if selected.get('url'):
|
||||||
|
info['thumbnail'] = selected['url']
|
||||||
|
|
||||||
|
|
||||||
|
class MusicMetadataPreProcessor(PostProcessor):
|
||||||
|
"""Enrich extracted audio metadata using extractor-owned album signals."""
|
||||||
|
|
||||||
|
def __init__(self, downloader=None, *, source_entry=None):
|
||||||
|
super().__init__(downloader)
|
||||||
|
self._source_entry = source_entry if isinstance(source_entry, dict) else {}
|
||||||
|
|
||||||
|
def run(self, info):
|
||||||
|
if _has_album_signal(info, self._source_entry):
|
||||||
|
number, inline_total = _track_position(info.get('track_number'))
|
||||||
|
if number is None:
|
||||||
|
number, source_inline_total = _track_position(
|
||||||
|
self._source_entry.get('track_number')
|
||||||
|
)
|
||||||
|
inline_total = inline_total or source_inline_total
|
||||||
|
if number is None:
|
||||||
|
number = _positive_int(self._source_entry.get('playlist_index'))
|
||||||
|
|
||||||
|
total = inline_total or _first_positive_int(
|
||||||
|
info.get('track_count'),
|
||||||
|
info.get('track_total'),
|
||||||
|
self._source_entry.get('track_count'),
|
||||||
|
self._source_entry.get('track_total'),
|
||||||
|
self._source_entry.get('playlist_count'),
|
||||||
|
self._source_entry.get('n_entries'),
|
||||||
|
)
|
||||||
|
if number is not None:
|
||||||
|
info['track_number'] = f'{number}/{total}' if total is not None else number
|
||||||
|
|
||||||
|
if not _has_value(info.get('album')):
|
||||||
|
album = self._source_entry.get('album') or self._source_entry.get(
|
||||||
|
'playlist_title'
|
||||||
|
)
|
||||||
|
if isinstance(album, str) and album.strip():
|
||||||
|
info['album'] = album.strip()
|
||||||
|
|
||||||
|
if _is_music_audio(info, self._source_entry):
|
||||||
|
prefer_square_thumbnail(info)
|
||||||
|
return [], info
|
||||||
+84
-3
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import collections.abc
|
import collections.abc
|
||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -17,6 +18,25 @@ STATE_SCHEMA_VERSION = 2
|
|||||||
_BYTES_MARKER = "__metube_bytes__"
|
_BYTES_MARKER = "__metube_bytes__"
|
||||||
_DATETIME_MARKER = "__metube_datetime__"
|
_DATETIME_MARKER = "__metube_datetime__"
|
||||||
|
|
||||||
|
# Errnos that signal the filesystem cannot support the temp-file + rename
|
||||||
|
# atomic-write strategy (for example an NFS-backed state dir returning EPERM on
|
||||||
|
# mkstemp). These are safe to fall back on because they mean the atomic
|
||||||
|
# mechanism is unavailable, not that the data write itself failed. Errors like
|
||||||
|
# ENOSPC/EIO are deliberately excluded so a genuine storage failure surfaces
|
||||||
|
# instead of silently truncating an existing good state file.
|
||||||
|
_ATOMIC_UNSUPPORTED_ERRNOS = frozenset(
|
||||||
|
e
|
||||||
|
for e in (
|
||||||
|
errno.EPERM,
|
||||||
|
errno.EACCES,
|
||||||
|
errno.ENOSYS,
|
||||||
|
errno.EINVAL,
|
||||||
|
getattr(errno, "EOPNOTSUPP", None),
|
||||||
|
getattr(errno, "ENOTSUP", None),
|
||||||
|
)
|
||||||
|
if e is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def to_json_compatible(value: Any) -> Any:
|
def to_json_compatible(value: Any) -> Any:
|
||||||
if value is None or isinstance(value, (bool, int, float, str)):
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
@@ -62,6 +82,7 @@ class AtomicJsonStore:
|
|||||||
self.path = path
|
self.path = path
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.schema_version = schema_version
|
self.schema_version = schema_version
|
||||||
|
self._direct_write_fallback_warned = False
|
||||||
|
|
||||||
def _ensure_parent(self) -> None:
|
def _ensure_parent(self) -> None:
|
||||||
parent = os.path.dirname(self.path)
|
parent = os.path.dirname(self.path)
|
||||||
@@ -96,6 +117,16 @@ class AtomicJsonStore:
|
|||||||
def save(self, data: dict[str, Any]) -> None:
|
def save(self, data: dict[str, Any]) -> None:
|
||||||
self._ensure_parent()
|
self._ensure_parent()
|
||||||
payload = self._build_payload(data)
|
payload = self._build_payload(data)
|
||||||
|
try:
|
||||||
|
self._atomic_write(payload)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
|
||||||
|
raise
|
||||||
|
self._warn_direct_write_fallback(exc)
|
||||||
|
self._direct_write(payload)
|
||||||
|
|
||||||
|
def _atomic_write(self, payload: dict[str, Any]) -> None:
|
||||||
|
text = self._serialize(payload)
|
||||||
parent = os.path.dirname(self.path) or "."
|
parent = os.path.dirname(self.path) or "."
|
||||||
fd, tmp_path = tempfile.mkstemp(
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
prefix=f".{os.path.basename(self.path)}.",
|
prefix=f".{os.path.basename(self.path)}.",
|
||||||
@@ -105,10 +136,9 @@ class AtomicJsonStore:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
|
f.write(text)
|
||||||
f.write("\n")
|
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
self._best_effort_fsync(f.fileno())
|
||||||
os.replace(tmp_path, self.path)
|
os.replace(tmp_path, self.path)
|
||||||
self._fsync_directory(parent)
|
self._fsync_directory(parent)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -118,6 +148,57 @@ class AtomicJsonStore:
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _direct_write(self, payload: dict[str, Any]) -> None:
|
||||||
|
# Serialize before truncating so a serialization failure never destroys
|
||||||
|
# the existing state file (the atomic path gets this for free via its
|
||||||
|
# temp file).
|
||||||
|
text = self._serialize(payload)
|
||||||
|
# Create with 0o600 so the fallback keeps the owner-only permissions the
|
||||||
|
# atomic path gets from mkstemp; state files can contain URLs and
|
||||||
|
# per-download option overrides that must not leak on shared mounts.
|
||||||
|
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
# The 0o600 mode above only applies when the file is created; force
|
||||||
|
# it on rewrites too so an existing, broadly-permissioned state file
|
||||||
|
# is tightened to match the atomic path. Best-effort because some
|
||||||
|
# network filesystems reject chmod, and that must not re-crash save.
|
||||||
|
try:
|
||||||
|
os.fchmod(f.fileno(), 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
f.write(text)
|
||||||
|
f.flush()
|
||||||
|
self._best_effort_fsync(f.fileno())
|
||||||
|
# Make the new directory entry durable too, matching the atomic path.
|
||||||
|
self._fsync_directory(os.path.dirname(self.path) or ".")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _best_effort_fsync(fileno: int) -> None:
|
||||||
|
# Tolerate fsync being unsupported on the underlying filesystem (for
|
||||||
|
# example a network mount that returns EINVAL/ENOSYS), but let genuine
|
||||||
|
# storage failures such as ENOSPC/EIO surface so a non-durable write is
|
||||||
|
# never reported as success. An unsupported fsync must not by itself
|
||||||
|
# abandon the atomic rename path.
|
||||||
|
try:
|
||||||
|
os.fsync(fileno)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize(payload: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||||
|
|
||||||
|
def _warn_direct_write_fallback(self, exc: OSError) -> None:
|
||||||
|
if self._direct_write_fallback_warned:
|
||||||
|
return
|
||||||
|
self._direct_write_fallback_warned = True
|
||||||
|
log.warning(
|
||||||
|
"Atomic state write failed for %s (%s); falling back to direct write",
|
||||||
|
self.path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
def quarantine_invalid_file(self, exc: Exception) -> None:
|
def quarantine_invalid_file(self, exc: Exception) -> None:
|
||||||
if not os.path.exists(self.path):
|
if not os.path.exists(self.path):
|
||||||
return
|
return
|
||||||
|
|||||||
+232
-22
@@ -11,14 +11,23 @@ import time
|
|||||||
import types
|
import types
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field, fields
|
from dataclasses import dataclass, field, fields
|
||||||
|
from functools import partial
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
import yt_dlp.networking.impersonate
|
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 state_store import AtomicJsonStore, read_legacy_shelf
|
||||||
|
from url_guard import validate_url
|
||||||
|
|
||||||
log = logging.getLogger("subscriptions")
|
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 = (
|
VIDEO_ONLY_MSG = (
|
||||||
"This URL points to a single video, not a channel or playlist. Use Download instead."
|
"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
|
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] = {
|
params: dict[str, Any] = {
|
||||||
"quiet": not logging.getLogger().isEnabledFor(logging.DEBUG),
|
"quiet": not logging.getLogger().isEnabledFor(logging.DEBUG),
|
||||||
"verbose": logging.getLogger().isEnabledFor(logging.DEBUG),
|
"verbose": logging.getLogger().isEnabledFor(logging.DEBUG),
|
||||||
@@ -52,6 +63,13 @@ def _build_ydl_params(config, *, playlistend: Optional[int] = None) -> dict:
|
|||||||
"lazy_playlist": True,
|
"lazy_playlist": True,
|
||||||
"paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR},
|
"paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR},
|
||||||
**config.YTDL_OPTIONS,
|
**config.YTDL_OPTIONS,
|
||||||
|
**(extra_opts or {}),
|
||||||
|
# A scan is a poll, not an add: it runs on a timer and queues items
|
||||||
|
# through the download queue, which writes the feed metadata itself.
|
||||||
|
# yt-dlp emits the playlist-level infojson/description/thumbnail
|
||||||
|
# regardless of `download`, so without this a writeinfojson user would
|
||||||
|
# get those files rewritten on every check interval. See issue #1040.
|
||||||
|
"allow_playlist_files": False,
|
||||||
}
|
}
|
||||||
params = _impersonate_opt(params)
|
params = _impersonate_opt(params)
|
||||||
if playlistend is not None and playlistend > 0:
|
if playlistend is not None and playlistend > 0:
|
||||||
@@ -76,9 +94,11 @@ def _is_media_entry(entry: Any) -> bool:
|
|||||||
return True
|
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."""
|
"""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:
|
with yt_dlp.YoutubeDL(params=params) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
if not info:
|
if not info:
|
||||||
@@ -100,10 +120,14 @@ def extract_flat_playlist(config, url: str, playlistend: int, *, _depth: int = 0
|
|||||||
nested_url = _entry_video_url(ent)
|
nested_url = _entry_video_url(ent)
|
||||||
if not nested_url:
|
if not nested_url:
|
||||||
continue
|
continue
|
||||||
|
# nested_url comes from remote playlist content; guard it too.
|
||||||
|
if validate_url(nested_url, allow_private=getattr(config, "ALLOW_PRIVATE_ADDRESSES", False)) is not None:
|
||||||
|
continue
|
||||||
nested_info, nested_entries = extract_flat_playlist(
|
nested_info, nested_entries = extract_flat_playlist(
|
||||||
config,
|
config,
|
||||||
nested_url,
|
nested_url,
|
||||||
playlistend,
|
playlistend,
|
||||||
|
extra_opts=extra_opts,
|
||||||
_depth=_depth + 1,
|
_depth=_depth + 1,
|
||||||
)
|
)
|
||||||
if nested_entries:
|
if nested_entries:
|
||||||
@@ -158,6 +182,7 @@ class SubscriptionInfo:
|
|||||||
auto_start: bool = True
|
auto_start: bool = True
|
||||||
playlist_item_limit: int = 0
|
playlist_item_limit: int = 0
|
||||||
split_by_chapters: bool = False
|
split_by_chapters: bool = False
|
||||||
|
sponsorblock: bool = False
|
||||||
chapter_template: str = ""
|
chapter_template: str = ""
|
||||||
subtitle_language: str = "en"
|
subtitle_language: str = "en"
|
||||||
subtitle_mode: str = "prefer_manual"
|
subtitle_mode: str = "prefer_manual"
|
||||||
@@ -165,6 +190,13 @@ class SubscriptionInfo:
|
|||||||
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
|
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
|
||||||
title_regex: str = ""
|
title_regex: str = ""
|
||||||
skip_subscriber_only: bool = False
|
skip_subscriber_only: bool = False
|
||||||
|
# A fixed range applied to every video the subscription downloads. Only
|
||||||
|
# sensible for channels with a consistent format (a standing intro, a fixed
|
||||||
|
# sponsor read); left unset, videos download whole. Old stored records
|
||||||
|
# predate these fields and take the defaults — _from_stored filters by
|
||||||
|
# field name, so nothing needs migrating.
|
||||||
|
clip_start: Optional[float] = None
|
||||||
|
clip_end: Optional[float] = None
|
||||||
last_checked: Optional[float] = None
|
last_checked: Optional[float] = None
|
||||||
seen_ids: list[str] = field(default_factory=list)
|
seen_ids: list[str] = field(default_factory=list)
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
@@ -187,6 +219,8 @@ class SubscriptionInfo:
|
|||||||
"folder": self.folder,
|
"folder": self.folder,
|
||||||
"title_regex": self.title_regex,
|
"title_regex": self.title_regex,
|
||||||
"skip_subscriber_only": self.skip_subscriber_only,
|
"skip_subscriber_only": self.skip_subscriber_only,
|
||||||
|
"clip_start": self.clip_start,
|
||||||
|
"clip_end": self.clip_end,
|
||||||
"last_checked": self.last_checked,
|
"last_checked": self.last_checked,
|
||||||
"seen_count": len(self.seen_ids),
|
"seen_count": len(self.seen_ids),
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
@@ -209,6 +243,7 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
|
|||||||
"auto_start": sub.auto_start,
|
"auto_start": sub.auto_start,
|
||||||
"playlist_item_limit": sub.playlist_item_limit,
|
"playlist_item_limit": sub.playlist_item_limit,
|
||||||
"split_by_chapters": sub.split_by_chapters,
|
"split_by_chapters": sub.split_by_chapters,
|
||||||
|
"sponsorblock": sub.sponsorblock,
|
||||||
"chapter_template": sub.chapter_template,
|
"chapter_template": sub.chapter_template,
|
||||||
"subtitle_language": sub.subtitle_language,
|
"subtitle_language": sub.subtitle_language,
|
||||||
"subtitle_mode": sub.subtitle_mode,
|
"subtitle_mode": sub.subtitle_mode,
|
||||||
@@ -216,6 +251,8 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
|
|||||||
"ytdl_options_overrides": sub.ytdl_options_overrides,
|
"ytdl_options_overrides": sub.ytdl_options_overrides,
|
||||||
"title_regex": sub.title_regex,
|
"title_regex": sub.title_regex,
|
||||||
"skip_subscriber_only": sub.skip_subscriber_only,
|
"skip_subscriber_only": sub.skip_subscriber_only,
|
||||||
|
"clip_start": sub.clip_start,
|
||||||
|
"clip_end": sub.clip_end,
|
||||||
"last_checked": sub.last_checked,
|
"last_checked": sub.last_checked,
|
||||||
"seen_ids": list(sub.seen_ids),
|
"seen_ids": list(sub.seen_ids),
|
||||||
"error": sub.error,
|
"error": sub.error,
|
||||||
@@ -269,6 +306,51 @@ def validate_title_regex(value: Any) -> str:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# The name is a display label the user picks; it is persisted and broadcast to
|
||||||
|
# every connected client, so keep it a bounded single-line string.
|
||||||
|
SUBSCRIPTION_NAME_MAX_LENGTH = 200
|
||||||
|
|
||||||
|
|
||||||
|
def validate_subscription_name(value: Any) -> str:
|
||||||
|
"""Return a stored subscription name, or raise ValueError if unusable."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("name must be a string")
|
||||||
|
# Collapse newlines/tabs so a pasted title can't break the table layout.
|
||||||
|
name = " ".join(value.split())
|
||||||
|
if not name:
|
||||||
|
raise ValueError("name must not be empty")
|
||||||
|
if len(name) > SUBSCRIPTION_NAME_MAX_LENGTH:
|
||||||
|
raise ValueError(f"name must be at most {SUBSCRIPTION_NAME_MAX_LENGTH} characters")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_subscription_folder(value: Any) -> str:
|
||||||
|
"""Return a stored subscription folder, or raise ValueError if unusable.
|
||||||
|
|
||||||
|
The folder is relative to the configured download directory, and the
|
||||||
|
authoritative check still happens at download time in ``DownloadQueue`` —
|
||||||
|
that is where ``CUSTOM_DIRS``, ``CREATE_CUSTOM_DIRS`` and the
|
||||||
|
resolves-inside-the-base-directory rule live, and where the directory is
|
||||||
|
created. This rejects only values that could never be valid, so an edit is
|
||||||
|
refused while the user is looking at it rather than silently failing every
|
||||||
|
check from then on. An empty folder is valid and means the base directory.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("folder must be a string")
|
||||||
|
folder = value.strip()
|
||||||
|
if not folder:
|
||||||
|
return ""
|
||||||
|
if os.path.isabs(folder):
|
||||||
|
raise ValueError("folder must be relative to the download directory")
|
||||||
|
# Check both separators: the value is stored as typed, and a Windows-style
|
||||||
|
# path would otherwise carry an unexamined '..' past this point.
|
||||||
|
if any(part == ".." for part in folder.replace("\\", "/").split("/")):
|
||||||
|
raise ValueError('folder must not contain ".."')
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
def _coerce_bool(value: Any) -> bool:
|
def _coerce_bool(value: Any) -> bool:
|
||||||
"""Accept JSON booleans and common string forms used by API clients."""
|
"""Accept JSON booleans and common string forms used by API clients."""
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
@@ -370,6 +452,23 @@ class SubscriptionManager:
|
|||||||
def _save_locked(self) -> None:
|
def _save_locked(self) -> None:
|
||||||
self._store.save({"items": [_subscription_to_record(sub) for sub in self._subs.values()]})
|
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(
|
async def _queue_subscription_entries(
|
||||||
self,
|
self,
|
||||||
entries: list[dict],
|
entries: list[dict],
|
||||||
@@ -388,6 +487,9 @@ class SubscriptionManager:
|
|||||||
subtitle_mode: str,
|
subtitle_mode: str,
|
||||||
ytdl_options_presets: Optional[list[str]] = None,
|
ytdl_options_presets: Optional[list[str]] = None,
|
||||||
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||||
|
clip_start: Optional[float] = None,
|
||||||
|
clip_end: Optional[float] = None,
|
||||||
|
sponsorblock: bool = False,
|
||||||
) -> tuple[list[str], list[str]]:
|
) -> tuple[list[str], list[str]]:
|
||||||
queued_ids: list[str] = []
|
queued_ids: list[str] = []
|
||||||
queue_errors: list[str] = []
|
queue_errors: list[str] = []
|
||||||
@@ -418,6 +520,9 @@ class SubscriptionManager:
|
|||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
presets,
|
presets,
|
||||||
ytdl_options_overrides,
|
ytdl_options_overrides,
|
||||||
|
clip_start,
|
||||||
|
clip_end,
|
||||||
|
sponsorblock=sponsorblock,
|
||||||
)
|
)
|
||||||
if isinstance(result, dict) and result.get("status") == "error":
|
if isinstance(result, dict) and result.get("status") == "error":
|
||||||
msg = str(result.get("msg") or f"Queueing failed for {vurl}")
|
msg = str(result.get("msg") or f"Queueing failed for {vurl}")
|
||||||
@@ -436,12 +541,8 @@ class SubscriptionManager:
|
|||||||
def start_background_loop(self) -> None:
|
def start_background_loop(self) -> None:
|
||||||
if self._loop_task is not None and not self._loop_task.done():
|
if self._loop_task is not None and not self._loop_task.done():
|
||||||
return
|
return
|
||||||
self._loop_task = asyncio.create_task(self._periodic_loop())
|
# bg_tasks.create_task already logs unexpected task failures with the name.
|
||||||
self._loop_task.add_done_callback(
|
self._loop_task = bg_tasks.create_task(self._periodic_loop(), name="subscription_loop")
|
||||||
lambda t: log.error("Subscription loop failed: %s", t.exception())
|
|
||||||
if not t.cancelled() and t.exception()
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _periodic_loop(self) -> None:
|
async def _periodic_loop(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
@@ -465,8 +566,30 @@ class SubscriptionManager:
|
|||||||
if now - sub.last_checked < interval_sec:
|
if now - sub.last_checked < interval_sec:
|
||||||
continue
|
continue
|
||||||
due.append(sub)
|
due.append(sub)
|
||||||
for sub in due:
|
await self._check_many(due)
|
||||||
await self._check_one_unlocked(sub)
|
|
||||||
|
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(
|
async def add_subscription(
|
||||||
self,
|
self,
|
||||||
@@ -487,12 +610,22 @@ class SubscriptionManager:
|
|||||||
subtitle_mode: str,
|
subtitle_mode: str,
|
||||||
ytdl_options_presets: Optional[list[str]] = None,
|
ytdl_options_presets: Optional[list[str]] = None,
|
||||||
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||||
|
sponsorblock: bool = False,
|
||||||
title_regex: Any = None,
|
title_regex: Any = None,
|
||||||
skip_subscriber_only: Any = None,
|
skip_subscriber_only: Any = None,
|
||||||
|
clip_start: Optional[float] = None,
|
||||||
|
clip_end: Optional[float] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
url = self._normalize_url(url)
|
url = self._normalize_url(url)
|
||||||
if not url:
|
if not url:
|
||||||
return {"status": "error", "msg": "Missing 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, partial(validate_url, url, allow_private=getattr(self.config, "ALLOW_PRIVATE_ADDRESSES", False)))
|
||||||
|
if url_error is not None:
|
||||||
|
log.warning('Rejected subscription URL "%s": %s', url, url_error)
|
||||||
|
return {"status": "error", "msg": url_error}
|
||||||
try:
|
try:
|
||||||
title_regex_stored = validate_title_regex(title_regex)
|
title_regex_stored = validate_title_regex(title_regex)
|
||||||
except re.error as exc:
|
except re.error as exc:
|
||||||
@@ -513,8 +646,12 @@ class SubscriptionManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
scan_first = max(int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)), 1)
|
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:
|
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:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
return {"status": "error", "msg": str(exc)}
|
return {"status": "error", "msg": str(exc)}
|
||||||
|
|
||||||
@@ -557,6 +694,7 @@ class SubscriptionManager:
|
|||||||
auto_start=bool(auto_start),
|
auto_start=bool(auto_start),
|
||||||
playlist_item_limit=int(playlist_item_limit),
|
playlist_item_limit=int(playlist_item_limit),
|
||||||
split_by_chapters=bool(split_by_chapters),
|
split_by_chapters=bool(split_by_chapters),
|
||||||
|
sponsorblock=bool(sponsorblock),
|
||||||
chapter_template=chapter_template or "",
|
chapter_template=chapter_template or "",
|
||||||
subtitle_language=subtitle_language,
|
subtitle_language=subtitle_language,
|
||||||
subtitle_mode=subtitle_mode,
|
subtitle_mode=subtitle_mode,
|
||||||
@@ -564,6 +702,8 @@ class SubscriptionManager:
|
|||||||
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
|
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
|
||||||
title_regex=title_regex_stored,
|
title_regex=title_regex_stored,
|
||||||
skip_subscriber_only=skip_so,
|
skip_subscriber_only=skip_so,
|
||||||
|
clip_start=clip_start,
|
||||||
|
clip_end=clip_end,
|
||||||
last_checked=time.time(),
|
last_checked=time.time(),
|
||||||
seen_ids=list(dict.fromkeys(all_ids)),
|
seen_ids=list(dict.fromkeys(all_ids)),
|
||||||
error=None,
|
error=None,
|
||||||
@@ -610,6 +750,20 @@ class SubscriptionManager:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
async def update_subscription(self, sub_id: str, changes: dict) -> dict:
|
async def update_subscription(self, sub_id: str, changes: dict) -> dict:
|
||||||
|
validated_name: Optional[str] = None
|
||||||
|
if "name" in changes:
|
||||||
|
try:
|
||||||
|
validated_name = validate_subscription_name(changes["name"])
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"status": "error", "msg": str(exc)}
|
||||||
|
|
||||||
|
validated_folder: Optional[str] = None
|
||||||
|
if "folder" in changes:
|
||||||
|
try:
|
||||||
|
validated_folder = validate_subscription_folder(changes["folder"])
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"status": "error", "msg": str(exc)}
|
||||||
|
|
||||||
validated_tr: Optional[str] = None
|
validated_tr: Optional[str] = None
|
||||||
if "title_regex" in changes:
|
if "title_regex" in changes:
|
||||||
try:
|
try:
|
||||||
@@ -629,6 +783,24 @@ class SubscriptionManager:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return {"status": "error", "msg": str(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:
|
async with self._lock:
|
||||||
sub = self._subs.get(sub_id)
|
sub = self._subs.get(sub_id)
|
||||||
if not sub:
|
if not sub:
|
||||||
@@ -636,12 +808,16 @@ class SubscriptionManager:
|
|||||||
previous = copy.deepcopy(sub)
|
previous = copy.deepcopy(sub)
|
||||||
old_enabled = sub.enabled
|
old_enabled = sub.enabled
|
||||||
|
|
||||||
if "enabled" in changes:
|
if enabled_set:
|
||||||
sub.enabled = _coerce_bool(changes["enabled"])
|
sub.enabled = validated_enabled
|
||||||
if "check_interval_minutes" in changes:
|
if interval_set:
|
||||||
sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"]))
|
sub.check_interval_minutes = validated_interval
|
||||||
if "name" in changes and changes["name"]:
|
if validated_name is not None:
|
||||||
sub.name = str(changes["name"])
|
sub.name = validated_name
|
||||||
|
if validated_folder is not None:
|
||||||
|
# Applies to future downloads only; files already downloaded
|
||||||
|
# stay where they are.
|
||||||
|
sub.folder = validated_folder
|
||||||
if validated_tr is not None:
|
if validated_tr is not None:
|
||||||
sub.title_regex = validated_tr
|
sub.title_regex = validated_tr
|
||||||
if skip_so_set:
|
if skip_so_set:
|
||||||
@@ -673,8 +849,7 @@ class SubscriptionManager:
|
|||||||
"Manual subscription check requested for %d subscription(s)",
|
"Manual subscription check requested for %d subscription(s)",
|
||||||
len(targets),
|
len(targets),
|
||||||
)
|
)
|
||||||
for sub in targets:
|
await self._check_many(targets)
|
||||||
await self._check_one_unlocked(sub)
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
||||||
@@ -696,15 +871,23 @@ class SubscriptionManager:
|
|||||||
async def _check_one_inner(self, sub: SubscriptionInfo) -> None:
|
async def _check_one_inner(self, sub: SubscriptionInfo) -> None:
|
||||||
sid = sub.id
|
sid = sub.id
|
||||||
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
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)
|
log.info("Checking subscription: %s", sub.name)
|
||||||
try:
|
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:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
cur = self._subs.get(sid)
|
cur = self._subs.get(sid)
|
||||||
if cur:
|
if cur:
|
||||||
previous = copy.deepcopy(cur)
|
previous = copy.deepcopy(cur)
|
||||||
cur.error = str(exc)
|
cur.error = str(exc)
|
||||||
|
cur.last_checked = time.time()
|
||||||
try:
|
try:
|
||||||
self._save_locked()
|
self._save_locked()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -717,12 +900,13 @@ class SubscriptionManager:
|
|||||||
entries = [ent for ent in entries if _is_media_entry(ent)]
|
entries = [ent for ent in entries if _is_media_entry(ent)]
|
||||||
|
|
||||||
etype = (info or {}).get("_type") or "video"
|
etype = (info or {}).get("_type") or "video"
|
||||||
if etype == "video" or not entries:
|
if etype == "video":
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
cur = self._subs.get(sid)
|
cur = self._subs.get(sid)
|
||||||
if cur:
|
if cur:
|
||||||
previous = copy.deepcopy(cur)
|
previous = copy.deepcopy(cur)
|
||||||
cur.error = VIDEO_ONLY_MSG
|
cur.error = VIDEO_ONLY_MSG
|
||||||
|
cur.last_checked = time.time()
|
||||||
try:
|
try:
|
||||||
self._save_locked()
|
self._save_locked()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -732,6 +916,22 @@ class SubscriptionManager:
|
|||||||
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
|
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
|
||||||
await self.notifier.subscription_updated(sub)
|
await self.notifier.subscription_updated(sub)
|
||||||
return
|
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:
|
async with self._lock:
|
||||||
cur = self._subs.get(sid)
|
cur = self._subs.get(sid)
|
||||||
@@ -748,6 +948,7 @@ class SubscriptionManager:
|
|||||||
dl_plimit = cur.playlist_item_limit
|
dl_plimit = cur.playlist_item_limit
|
||||||
dl_autostart = cur.auto_start
|
dl_autostart = cur.auto_start
|
||||||
dl_split = cur.split_by_chapters
|
dl_split = cur.split_by_chapters
|
||||||
|
dl_sponsorblock = cur.sponsorblock
|
||||||
dl_chapter = cur.chapter_template
|
dl_chapter = cur.chapter_template
|
||||||
dl_sublang = cur.subtitle_language
|
dl_sublang = cur.subtitle_language
|
||||||
dl_submode = cur.subtitle_mode
|
dl_submode = cur.subtitle_mode
|
||||||
@@ -755,12 +956,18 @@ class SubscriptionManager:
|
|||||||
dl_ytdl_overrides = dict(cur.ytdl_options_overrides)
|
dl_ytdl_overrides = dict(cur.ytdl_options_overrides)
|
||||||
dl_title_regex = cur.title_regex or ""
|
dl_title_regex = cur.title_regex or ""
|
||||||
dl_skip_subscriber_only = bool(cur.skip_subscriber_only)
|
dl_skip_subscriber_only = bool(cur.skip_subscriber_only)
|
||||||
|
dl_clip_start = cur.clip_start
|
||||||
|
dl_clip_end = cur.clip_end
|
||||||
|
|
||||||
new_entries: list[dict] = []
|
new_entries: list[dict] = []
|
||||||
for ent in entries:
|
for ent in entries:
|
||||||
eid = _entry_id(ent)
|
eid = _entry_id(ent)
|
||||||
if not eid:
|
if not eid:
|
||||||
continue
|
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":
|
if eid in seen and ent.get("live_status") != "is_live":
|
||||||
continue
|
continue
|
||||||
new_entries.append(ent)
|
new_entries.append(ent)
|
||||||
@@ -810,11 +1017,14 @@ class SubscriptionManager:
|
|||||||
playlist_item_limit=dl_plimit,
|
playlist_item_limit=dl_plimit,
|
||||||
auto_start=dl_autostart,
|
auto_start=dl_autostart,
|
||||||
split_by_chapters=dl_split,
|
split_by_chapters=dl_split,
|
||||||
|
sponsorblock=dl_sponsorblock,
|
||||||
chapter_template=dl_chapter or "",
|
chapter_template=dl_chapter or "",
|
||||||
subtitle_language=dl_sublang,
|
subtitle_language=dl_sublang,
|
||||||
subtitle_mode=dl_submode,
|
subtitle_mode=dl_submode,
|
||||||
ytdl_options_presets=dl_ytdl_presets,
|
ytdl_options_presets=dl_ytdl_presets,
|
||||||
ytdl_options_overrides=dl_ytdl_overrides,
|
ytdl_options_overrides=dl_ytdl_overrides,
|
||||||
|
clip_start=dl_clip_start,
|
||||||
|
clip_end=dl_clip_end,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"Subscription check finished for %s: %d new, %d filtered, %d subscriber_skipped, %d queued, %d failed",
|
"Subscription check finished for %s: %d new, %d filtered, %d subscriber_skipped, %d queued, %d failed",
|
||||||
|
|||||||
+204
-26
@@ -6,6 +6,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -19,7 +20,9 @@ def mock_dqueue(monkeypatch):
|
|||||||
d = MagicMock()
|
d = MagicMock()
|
||||||
d.initialize = AsyncMock(return_value=None)
|
d.initialize = AsyncMock(return_value=None)
|
||||||
d.add = AsyncMock(return_value={"status": "ok"})
|
d.add = AsyncMock(return_value={"status": "ok"})
|
||||||
|
d.retry = AsyncMock(return_value={"status": "ok"})
|
||||||
d.cancel = 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.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||||
d.cancel_add = MagicMock()
|
d.cancel_add = MagicMock()
|
||||||
d.queue = MagicMock()
|
d.queue = MagicMock()
|
||||||
@@ -28,6 +31,9 @@ def mock_dqueue(monkeypatch):
|
|||||||
d.queue.saved_items = MagicMock(return_value=[])
|
d.queue.saved_items = MagicMock(return_value=[])
|
||||||
d.done.saved_items = MagicMock(return_value=[])
|
d.done.saved_items = MagicMock(return_value=[])
|
||||||
d.pending.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=([], []))
|
d.get = MagicMock(return_value=([], []))
|
||||||
monkeypatch.setattr(main, "dqueue", d)
|
monkeypatch.setattr(main, "dqueue", d)
|
||||||
return d
|
return d
|
||||||
@@ -64,6 +70,22 @@ async def test_add_ok(mock_dqueue):
|
|||||||
mock_dqueue.add.assert_awaited_once()
|
mock_dqueue.add.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_passes_failed_download_id(mock_dqueue):
|
||||||
|
req = _json_request({"id": "https://example.com/watch?v=1"})
|
||||||
|
resp = await main.retry(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
mock_dqueue.retry.assert_awaited_once_with("https://example.com/watch?v=1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("body", [{}, {"id": ""}, {"id": ["a"]}, {"ids": ["a"]}])
|
||||||
|
async def test_retry_rejects_missing_or_non_string_id(mock_dqueue, body):
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.retry(_json_request(body))
|
||||||
|
mock_dqueue.retry.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
|
async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
|
||||||
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}})
|
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}})
|
||||||
@@ -133,25 +155,6 @@ async def test_add_invalid_subtitle_language(mock_dqueue):
|
|||||||
await main.add(req)
|
await main.add(req)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_custom_name_prefix_path_traversal(mock_dqueue):
|
|
||||||
req = _json_request(_valid_video_add_body(custom_name_prefix="../evil"))
|
|
||||||
with pytest.raises(web.HTTPBadRequest):
|
|
||||||
await main.add(req)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_chapter_template_path_traversal(mock_dqueue):
|
|
||||||
req = _json_request(
|
|
||||||
_valid_video_add_body(
|
|
||||||
split_by_chapters=True,
|
|
||||||
chapter_template="/etc/passwd%(title)s",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
with pytest.raises(web.HTTPBadRequest):
|
|
||||||
await main.add(req)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_invalid_json_body(mock_dqueue):
|
async def test_add_invalid_json_body(mock_dqueue):
|
||||||
req = MagicMock(spec=web.Request)
|
req = MagicMock(spec=web.Request)
|
||||||
@@ -215,11 +218,35 @@ async def test_start_pending(mock_dqueue):
|
|||||||
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_history_shape(mock_dqueue):
|
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)
|
req = MagicMock(spec=web.Request)
|
||||||
resp = await main.history(req)
|
resp = await main.history(req)
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
@@ -227,6 +254,30 @@ async def test_history_shape(mock_dqueue):
|
|||||||
assert set(data.keys()) == {"done", "queue", "pending"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_version_json(mock_dqueue):
|
async def test_version_json(mock_dqueue):
|
||||||
req = MagicMock(spec=web.Request)
|
req = MagicMock(spec=web.Request)
|
||||||
@@ -298,17 +349,134 @@ async def test_add_passes_clip_bounds_to_queue(mock_dqueue):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
|
async def test_subscribe_passes_clip_bounds(mock_dqueue, monkeypatch):
|
||||||
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock())
|
"""Issue #1049: a subscription's options apply to every future download, and
|
||||||
|
clip bounds were the one option carved out of that."""
|
||||||
|
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||||
req = _json_request(
|
req = _json_request(
|
||||||
{
|
{
|
||||||
**_valid_video_add_body(clip_start="10"),
|
**_valid_video_add_body(clip_start="2:26", clip_end="3:24"),
|
||||||
"check_interval_minutes": 60,
|
"check_interval_minutes": 60,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
resp = await main.subscribe(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
kwargs = main.submgr.add_subscription.await_args.kwargs
|
||||||
|
assert kwargs["clip_start"] == pytest.approx(146.0)
|
||||||
|
assert kwargs["clip_end"] == pytest.approx(204.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_passes_sponsorblock(mock_dqueue, monkeypatch):
|
||||||
|
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||||
|
req = _json_request(
|
||||||
|
{**_valid_video_add_body(), "check_interval_minutes": 60, "sponsorblock": True}
|
||||||
|
)
|
||||||
|
resp = await main.subscribe(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_defaults_sponsorblock_off(mock_dqueue, monkeypatch):
|
||||||
|
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||||
|
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
|
||||||
|
await main.subscribe(req)
|
||||||
|
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_without_clip_fields_stores_none(mock_dqueue, monkeypatch):
|
||||||
|
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||||
|
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
|
||||||
|
await main.subscribe(req)
|
||||||
|
kwargs = main.submgr.add_subscription.await_args.kwargs
|
||||||
|
assert kwargs["clip_start"] is None
|
||||||
|
assert kwargs["clip_end"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_ignores_t_param_in_url(mock_dqueue, monkeypatch):
|
||||||
|
"""A t= timestamp means "start here" for a one-off download of that video.
|
||||||
|
On a channel or playlist URL it says nothing about the videos it yields, so
|
||||||
|
it must not silently clip every future download."""
|
||||||
|
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||||
|
body = _valid_video_add_body()
|
||||||
|
# t= is only honoured on YouTube hosts, so this must be one to exercise it.
|
||||||
|
body["url"] = "https://www.youtube.com/@somechannel?t=90"
|
||||||
|
req = _json_request({**body, "check_interval_minutes": 60})
|
||||||
|
await main.subscribe(req)
|
||||||
|
kwargs = main.submgr.add_subscription.await_args.kwargs
|
||||||
|
assert kwargs["clip_start"] is None
|
||||||
|
assert kwargs["clip_end"] is None
|
||||||
|
# The timestamp is still stripped from the URL that gets stored.
|
||||||
|
assert "t=90" not in main.submgr.add_subscription.await_args.args[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_explicit_clip_wins_over_t_param(mock_dqueue, monkeypatch):
|
||||||
|
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||||
|
body = _valid_video_add_body(clip_start="30")
|
||||||
|
body["url"] = "https://www.youtube.com/@somechannel?t=90"
|
||||||
|
req = _json_request({**body, "check_interval_minutes": 60})
|
||||||
|
await main.subscribe(req)
|
||||||
|
kwargs = main.submgr.add_subscription.await_args.kwargs
|
||||||
|
assert kwargs["clip_start"] == pytest.approx(30.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_still_rejects_clips_for_non_media(mock_dqueue, monkeypatch):
|
||||||
|
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
|
||||||
|
body = _valid_video_add_body(clip_start="10")
|
||||||
|
body["download_type"] = "thumbnail"
|
||||||
|
req = _json_request({**body, "check_interval_minutes": 60})
|
||||||
with pytest.raises(web.HTTPBadRequest):
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
await main.subscribe(req)
|
await main.subscribe(req)
|
||||||
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"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscriptions_update_accepts_folder(monkeypatch, mock_dqueue):
|
||||||
|
"""Issue #1052: folder was absent from the route's accepted fields, so a
|
||||||
|
folder-only update was rejected outright as having nothing to update."""
|
||||||
|
submgr = MagicMock()
|
||||||
|
submgr.update_subscription = AsyncMock(return_value={"status": "ok"})
|
||||||
|
monkeypatch.setattr(main, "submgr", submgr)
|
||||||
|
|
||||||
|
req = _json_request({"id": "abc", "folder": "channels/jane"})
|
||||||
|
resp = await main.subscriptions_update(req)
|
||||||
|
|
||||||
|
assert resp.status == 200
|
||||||
|
submgr.update_subscription.assert_awaited_once_with("abc", {"folder": "channels/jane"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscriptions_update_still_drops_unknown_fields(monkeypatch, mock_dqueue):
|
||||||
|
submgr = MagicMock()
|
||||||
|
submgr.update_subscription = AsyncMock(return_value={"status": "ok"})
|
||||||
|
monkeypatch.setattr(main, "submgr", submgr)
|
||||||
|
|
||||||
|
req = _json_request({"id": "abc", "seen_ids": ["x"], "url": "https://evil.example"})
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.subscriptions_update(req)
|
||||||
|
submgr.update_subscription.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_is_within_state_dir_blocks_state_subtree():
|
def test_is_within_state_dir_blocks_state_subtree():
|
||||||
@@ -331,6 +499,11 @@ async def test_download_blocks_state_dir_files(monkeypatch):
|
|||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
|
(state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
|
||||||
(download_dir / "video.mp4").write_bytes(b"video")
|
(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.config, "STATE_DIR", str(state_dir))
|
||||||
monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir)))
|
monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir)))
|
||||||
@@ -343,7 +516,12 @@ async def test_download_blocks_state_dir_files(monkeypatch):
|
|||||||
allowed = await client.get("/download/video.mp4")
|
allowed = await client.get("/download/video.mp4")
|
||||||
assert allowed.status == 200
|
assert allowed.status == 200
|
||||||
assert await allowed.read() == b"video"
|
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:
|
finally:
|
||||||
(state_dir / "cookies.txt").unlink(missing_ok=True)
|
(state_dir / "cookies.txt").unlink(missing_ok=True)
|
||||||
(download_dir / "video.mp4").unlink(missing_ok=True)
|
(download_dir / "video.mp4").unlink(missing_ok=True)
|
||||||
|
(download_dir / percent_filename).unlink(missing_ok=True)
|
||||||
state_dir.rmdir()
|
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)
|
||||||
@@ -115,6 +115,28 @@ class ConfigTests(unittest.TestCase):
|
|||||||
self.assertNotIn("HOST", safe)
|
self.assertNotIn("HOST", safe)
|
||||||
self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False)
|
self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False)
|
||||||
|
|
||||||
|
def test_default_folder_empty_by_default(self):
|
||||||
|
with patch.dict(os.environ, _base_env(), clear=False):
|
||||||
|
c = Config()
|
||||||
|
self.assertEqual(c.DEFAULT_FOLDER, "")
|
||||||
|
|
||||||
|
def test_default_folder_is_trimmed_and_reaches_the_frontend(self):
|
||||||
|
with patch.dict(os.environ, _base_env(DEFAULT_FOLDER=" /youtube/ "), clear=False):
|
||||||
|
c = Config()
|
||||||
|
self.assertEqual(c.DEFAULT_FOLDER, "youtube")
|
||||||
|
self.assertEqual(c.frontend_safe()["DEFAULT_FOLDER"], "youtube")
|
||||||
|
|
||||||
|
def test_default_folder_ignored_without_custom_dirs(self):
|
||||||
|
# The folder field is not shown at all without CUSTOM_DIRS, and sending
|
||||||
|
# a folder anyway is rejected by the download path check.
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
_base_env(DEFAULT_FOLDER="youtube", CUSTOM_DIRS="false"),
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
c = Config()
|
||||||
|
self.assertEqual(c.DEFAULT_FOLDER, "")
|
||||||
|
|
||||||
def test_allow_ytdl_options_overrides_boolean_loaded(self):
|
def test_allow_ytdl_options_overrides_boolean_loaded(self):
|
||||||
with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False):
|
with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False):
|
||||||
c = Config()
|
c = Config()
|
||||||
@@ -159,6 +181,35 @@ class ConfigTests(unittest.TestCase):
|
|||||||
c = Config()
|
c = Config()
|
||||||
self.assertEqual(c.CLEAR_COMPLETED_AFTER, "0")
|
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):
|
def test_runtime_override_roundtrip(self):
|
||||||
with patch.dict(os.environ, _base_env(), clear=False):
|
with patch.dict(os.environ, _base_env(), clear=False):
|
||||||
c = Config()
|
c = Config()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.dl_formats import (
|
|||||||
_normalize_subtitle_language,
|
_normalize_subtitle_language,
|
||||||
get_format,
|
get_format,
|
||||||
get_opts,
|
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):
|
def test_get_opts_captions_txt_maps_to_srt_format(self):
|
||||||
opts = get_opts("captions", "auto", "txt", "best", {})
|
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):
|
def test_get_opts_merges_existing_postprocessors(self):
|
||||||
opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]})
|
opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]})
|
||||||
@@ -135,5 +160,23 @@ class DlFormatsTests(unittest.TestCase):
|
|||||||
self.assertEqual(_normalize_subtitle_language(" "), "en")
|
self.assertEqual(_normalize_subtitle_language(" "), "en")
|
||||||
|
|
||||||
|
|
||||||
|
class MergeYtdlOptionLayersTests(unittest.TestCase):
|
||||||
|
def test_presets_applied_in_order_then_overrides(self):
|
||||||
|
presets_config = {
|
||||||
|
"a": {"x": 1, "y": 1},
|
||||||
|
"b": {"y": 2, "z": 2},
|
||||||
|
}
|
||||||
|
merged = merge_ytdl_option_layers(["a", "b"], {"z": 3, "w": 4}, presets_config)
|
||||||
|
# b overrides a's y; explicit overrides win over presets.
|
||||||
|
self.assertEqual(merged, {"x": 1, "y": 2, "z": 3, "w": 4})
|
||||||
|
|
||||||
|
def test_no_base_options_included(self):
|
||||||
|
# The helper only produces the preset/override layer, never base opts.
|
||||||
|
self.assertEqual(merge_ytdl_option_layers(None, None, {}), {})
|
||||||
|
|
||||||
|
def test_unknown_preset_names_ignored(self):
|
||||||
|
self.assertEqual(merge_ytdl_option_layers(["missing"], {"a": 1}, {}), {"a": 1})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ytdl import DownloadInfo, DownloadQueue
|
from ytdl import Download, DownloadInfo, DownloadQueue
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -46,6 +48,37 @@ def test_cancel_add_increments_generation(dq_env):
|
|||||||
assert dq._add_generation == before + 1
|
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):
|
def test_get_returns_tuple_of_lists(dq_env):
|
||||||
notifier = MagicMock()
|
notifier = MagicMock()
|
||||||
dq = DownloadQueue(dq_env, notifier)
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
@@ -57,7 +90,7 @@ def test_get_returns_tuple_of_lists(dq_env):
|
|||||||
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
|
|
||||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
return {
|
return {
|
||||||
"_type": "video",
|
"_type": "video",
|
||||||
"id": "vid1",
|
"id": "vid1",
|
||||||
@@ -83,11 +116,59 @@ async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
|||||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_unsupported_url_recorded_as_failed_entry(dq_env):
|
||||||
|
"""An unsupported/unextractable URL must show up as a red-cross entry in the
|
||||||
|
done list, not just a transient toast and a server log line."""
|
||||||
|
import ytdl
|
||||||
|
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/not-a-video"
|
||||||
|
|
||||||
|
def boom(self, url, *_args, **_kwargs):
|
||||||
|
raise ytdl.yt_dlp.utils.YoutubeDLError(f'Unsupported URL: {url}')
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||||
|
result = await dq.add(
|
||||||
|
url, "video", "auto", "any", "best", "", "", 0, auto_start=True,
|
||||||
|
)
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert dq.done.exists(url)
|
||||||
|
failed = dq.done.get(url)
|
||||||
|
assert failed.info.status == "error"
|
||||||
|
assert failed.info.error == result["msg"]
|
||||||
|
assert failed.info.url == url
|
||||||
|
# The full URL stays in .url/.error for the detail panel; the display
|
||||||
|
# title is shortened to the hostname so the Completed row stays readable.
|
||||||
|
assert failed.info.title == "example.com"
|
||||||
|
notifier.completed.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_ssrf_rejected_url_recorded_as_failed_entry(dq_env):
|
||||||
|
"""A URL rejected by the SSRF guard (before yt-dlp ever runs) must also
|
||||||
|
surface as a failed entry, not just an error status returned to the caller."""
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "file:///etc/passwd"
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
result = await dq.add(
|
||||||
|
url, "video", "auto", "any", "best", "", "", 0, auto_start=True,
|
||||||
|
)
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert dq.done.exists(url)
|
||||||
|
failed = dq.done.get(url)
|
||||||
|
assert failed.info.status == "error"
|
||||||
|
assert failed.info.error == result["msg"]
|
||||||
|
notifier.completed.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_removes_from_pending(dq_env):
|
async def test_cancel_removes_from_pending(dq_env):
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
|
|
||||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
return {
|
return {
|
||||||
"_type": "video",
|
"_type": "video",
|
||||||
"id": "vid1",
|
"id": "vid1",
|
||||||
@@ -124,7 +205,7 @@ async def test_cancel_before_start_marks_download_canceled(dq_env):
|
|||||||
cancelling, because its ``download.canceled`` guard was never flipped."""
|
cancelling, because its ``download.canceled`` guard was never flipped."""
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
|
|
||||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
return {
|
return {
|
||||||
"_type": "video",
|
"_type": "video",
|
||||||
"id": "vid1",
|
"id": "vid1",
|
||||||
@@ -162,7 +243,7 @@ async def test_cancel_before_start_marks_download_canceled(dq_env):
|
|||||||
async def test_start_pending_moves_to_queue(dq_env):
|
async def test_start_pending_moves_to_queue(dq_env):
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
|
|
||||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
return {
|
return {
|
||||||
"_type": "video",
|
"_type": "video",
|
||||||
"id": "vid1",
|
"id": "vid1",
|
||||||
@@ -222,6 +303,430 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
|||||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_restores_playlist_output_context(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
failed_info = DownloadInfo(
|
||||||
|
id="vid1",
|
||||||
|
title="Test Video",
|
||||||
|
url=url,
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error="temporary failure",
|
||||||
|
entry={
|
||||||
|
"playlist_index": "01",
|
||||||
|
"playlist_title": "My Playlist",
|
||||||
|
"playlist_count": 10,
|
||||||
|
},
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
)
|
||||||
|
failed_info.status = "error"
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, failed_info))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
queued = dq.queue.get(url)
|
||||||
|
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
||||||
|
assert queued.info.entry["playlist_index"] == "01"
|
||||||
|
assert queued.info.entry["playlist_title"] == "My Playlist"
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_playlist_item(url, **overrides):
|
||||||
|
"""A done-list entry for a playlist item that failed mid-download."""
|
||||||
|
info = DownloadInfo(
|
||||||
|
id="vid1",
|
||||||
|
title="Test Video",
|
||||||
|
url=url,
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error="temporary failure",
|
||||||
|
entry={
|
||||||
|
"playlist_index": "01",
|
||||||
|
"playlist_title": "My Playlist",
|
||||||
|
"playlist_count": 10,
|
||||||
|
},
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
info.status = "error"
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_keeps_playlist_context_through_url_indirection(dq_env):
|
||||||
|
# extract_flat=True makes yt-dlp hand back url/url_transparent results
|
||||||
|
# unprocessed, so __add_entry recurses into add() a second time. The retry
|
||||||
|
# context has to survive that hop or the item lands in the root directory.
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
resolved = "https://example.com/resolved?v=1"
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url)))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||||
|
if extracted_url == url:
|
||||||
|
return {"_type": "url", "url": resolved, "id": "vid1"}
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
queued = dq.queue.get(resolved)
|
||||||
|
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
||||||
|
assert queued.info.entry["playlist_title"] == "My Playlist"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_reapplies_current_options_gates(dq_env):
|
||||||
|
# The stored options passed parse_download_options when first submitted, but
|
||||||
|
# the configuration can have changed since; retry must not resurrect
|
||||||
|
# overrides or presets the current configuration no longer allows.
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = False
|
||||||
|
dq_env.YTDL_OPTIONS_PRESETS = {"Still There": {"writesubtitles": True}}
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
info = _failed_playlist_item(
|
||||||
|
url,
|
||||||
|
ytdl_options_presets=["Still There", "Removed Preset"],
|
||||||
|
ytdl_options_overrides={"paths": {"home": "/etc"}},
|
||||||
|
)
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
queued = dq.queue.get(url)
|
||||||
|
assert queued.info.ytdl_options_overrides == {}
|
||||||
|
assert queued.info.ytdl_options_presets == ["Still There"]
|
||||||
|
assert queued.ytdl_opts.get("paths", {}).get("home") != "/etc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_keeps_overrides_while_still_allowed(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = True
|
||||||
|
dq_env.YTDL_OPTIONS_PRESETS = {}
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
info = _failed_playlist_item(url, ytdl_options_overrides={"writesubtitles": True})
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_carries_the_sponsorblock_flag(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
dq.done.put(
|
||||||
|
Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url, sponsorblock=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert dq.queue.get(url).info.sponsorblock is True
|
||||||
|
|
||||||
|
|
||||||
|
@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, *_args, **_kwargs):
|
||||||
|
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, *_args, **_kwargs):
|
||||||
|
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/")
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_extraction(entry_id, **extra):
|
||||||
|
"""A channel yt-dlp reported as a playlist, addressed by *entry_id*."""
|
||||||
|
return {
|
||||||
|
"_type": "playlist",
|
||||||
|
"id": entry_id,
|
||||||
|
"channel_id": "UCabcd123",
|
||||||
|
"channel": "Odin",
|
||||||
|
"title": "Odin",
|
||||||
|
**extra,
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_and_get_template(dq_env, extraction, url):
|
||||||
|
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, *_args, **_kwargs):
|
||||||
|
return extraction
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||||
|
result = await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=False)
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
return dq.pending.get("https://example.com/watch?v=1").output_template
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bare_handle_channel_url_is_treated_as_a_channel(dq_env):
|
||||||
|
"""A channel addressed as /@handle reports its id as the handle, not the
|
||||||
|
channel id, and was falling through to OUTPUT_TEMPLATE_PLAYLIST."""
|
||||||
|
template = await _add_and_get_template(
|
||||||
|
dq_env,
|
||||||
|
_channel_extraction("@odin", uploader_id="@odin"),
|
||||||
|
"https://www.youtube.com/@odin",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.startswith("Odin [YT]/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_vanity_channel_url_is_treated_as_a_channel(dq_env):
|
||||||
|
"""A legacy /c/Name URL reports the vanity name as its id, while
|
||||||
|
uploader_id is still the handle."""
|
||||||
|
template = await _add_and_get_template(
|
||||||
|
dq_env,
|
||||||
|
_channel_extraction("Odin", uploader_id="@odin"),
|
||||||
|
"https://www.youtube.com/c/Odin",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.startswith("Odin [YT]/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_playlist_with_owner_uploader_id_is_still_a_playlist(dq_env):
|
||||||
|
"""A real playlist carries its owner's channel_id and uploader_id, but its
|
||||||
|
own id matches neither, so it must keep the playlist template."""
|
||||||
|
template = await _add_and_get_template(
|
||||||
|
dq_env,
|
||||||
|
_channel_extraction("PLxyz789", uploader_id="@odin", title="My Playlist"),
|
||||||
|
"https://www.youtube.com/playlist?list=PLxyz789",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.startswith("My Playlist/")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_merges_global_preset_and_override_options(dq_env):
|
async def test_add_merges_global_preset_and_override_options(dq_env):
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
@@ -231,7 +736,7 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
|
|||||||
"Preset B": {"writesubtitles": False, "ratelimit": 1000},
|
"Preset B": {"writesubtitles": False, "ratelimit": 1000},
|
||||||
}
|
}
|
||||||
|
|
||||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
return {
|
return {
|
||||||
"_type": "video",
|
"_type": "video",
|
||||||
"id": "vid2",
|
"id": "vid2",
|
||||||
@@ -354,11 +859,191 @@ async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
|
|||||||
assert captured_params[0]["noplaylist"] is True
|
assert captured_params[0]["noplaylist"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_extract(feed):
|
||||||
|
"""Patch for __extract_info that returns a playlist/channel feed dict."""
|
||||||
|
|
||||||
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
|
return copy.deepcopy(feed)
|
||||||
|
|
||||||
|
return fake_extract
|
||||||
|
|
||||||
|
|
||||||
|
_CHANNEL_FEED = {
|
||||||
|
"_type": "playlist",
|
||||||
|
"id": "UC123",
|
||||||
|
"title": "Vanessa - Videos",
|
||||||
|
"channel": "Vanessa",
|
||||||
|
"channel_id": "UC123",
|
||||||
|
"uploader": "Vanessa",
|
||||||
|
"extractor": "youtube:tab",
|
||||||
|
"extractor_key": "YoutubeTab",
|
||||||
|
"webpage_url": "https://example.com/@vanessa/videos",
|
||||||
|
"entries": [
|
||||||
|
{"id": "v1", "title": "One", "url": "https://example.com/v1",
|
||||||
|
"webpage_url": "https://example.com/v1", "_type": "url"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_PLAYLIST_FEED = {
|
||||||
|
"_type": "playlist",
|
||||||
|
"id": "PL123",
|
||||||
|
"title": "My Playlist",
|
||||||
|
"extractor": "generic",
|
||||||
|
"extractor_key": "Generic",
|
||||||
|
"webpage_url": "https://example.com/playlist?list=PL123",
|
||||||
|
"entries": [
|
||||||
|
{"id": "v1", "title": "One", "url": "https://example.com/v1",
|
||||||
|
"webpage_url": "https://example.com/v1", "_type": "url"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _written_files(root):
|
||||||
|
found = []
|
||||||
|
for dirpath, _dirs, files in os.walk(root):
|
||||||
|
for f in files:
|
||||||
|
found.append(os.path.relpath(os.path.join(dirpath, f), root))
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_channel_feed_metadata_lands_beside_its_items(dq_env):
|
||||||
|
"""Issues #660/#1040: the feed-level .info.json follows the same template
|
||||||
|
the items use, so it sits in the channel's own folder rather than in
|
||||||
|
DOWNLOAD_DIR under yt-dlp's pl_* default name."""
|
||||||
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||||
|
dq_env.OUTPUT_TEMPLATE_CHANNEL = "%(channel)s/%(title)s.%(ext)s"
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_CHANNEL_FEED)), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.add(
|
||||||
|
"https://example.com/@vanessa/videos", "video", "auto", "any", "best",
|
||||||
|
"", "", 0, auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
||||||
|
os.path.join("Vanessa", "Vanessa - Videos.info.json")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_playlist_feed_metadata_uses_the_playlist_template(dq_env):
|
||||||
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||||
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
await dq.add(
|
||||||
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||||
|
"", "", 0, auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
||||||
|
os.path.join("My Playlist", "My Playlist.info.json")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feed_metadata_honours_custom_folder(dq_env):
|
||||||
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||||
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
await dq.add(
|
||||||
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||||
|
"Music", "", 0, auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
||||||
|
os.path.join("Music", "My Playlist", "My Playlist.info.json")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_feed_metadata_without_writeinfojson(dq_env):
|
||||||
|
"""Nothing new appears for users who never asked for these files."""
|
||||||
|
dq_env.YTDL_OPTIONS = {}
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
await dq.add(
|
||||||
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||||
|
"", "", 0, auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _written_files(dq_env.DOWNLOAD_DIR) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feed_metadata_can_be_turned_off_by_the_user(dq_env):
|
||||||
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True, "allow_playlist_files": False}
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
await dq.add(
|
||||||
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||||
|
"", "", 0, auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _written_files(dq_env.DOWNLOAD_DIR) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feed_metadata_failure_does_not_fail_the_add(dq_env):
|
||||||
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()), \
|
||||||
|
patch.object(
|
||||||
|
DownloadQueue, "_DownloadQueue__write_feed_metadata_sync",
|
||||||
|
side_effect=OSError("read-only filesystem"),
|
||||||
|
):
|
||||||
|
result = await dq.add(
|
||||||
|
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||||
|
"", "", 0, auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert dq.pending.exists("https://example.com/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extraction_pass_never_writes_feed_metadata(dq_env):
|
||||||
|
"""The classification pass must not produce files: it runs before the add is
|
||||||
|
known to succeed, and yt-dlp writes playlist files regardless of `download`."""
|
||||||
|
dq_env.YTDL_OPTIONS = {"writeinfojson": True, "allow_playlist_files": True}
|
||||||
|
captured: list = []
|
||||||
|
|
||||||
|
class FakeYoutubeDL:
|
||||||
|
def __init__(self, params=None):
|
||||||
|
captured.append(params)
|
||||||
|
|
||||||
|
def extract_info(self, url, download=False):
|
||||||
|
return {"_type": "video", "id": "v", "title": "V", "url": url, "webpage_url": url}
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, AsyncMock())
|
||||||
|
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
|
||||||
|
await dq.add(
|
||||||
|
"https://example.com/watch?v=1", "video", "auto", "any", "best",
|
||||||
|
"", "", 0, auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured[0]["allow_playlist_files"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
|
|
||||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
return {
|
return {
|
||||||
"_type": "video",
|
"_type": "video",
|
||||||
"id": "vid1",
|
"id": "vid1",
|
||||||
@@ -429,6 +1114,9 @@ async def test_add_upcoming_stream_scheduled_without_starting(dq_env):
|
|||||||
assert download.info.live_release_timestamp is not None
|
assert download.info.live_release_timestamp is not None
|
||||||
start_mock.assert_not_called()
|
start_mock.assert_not_called()
|
||||||
assert url in dq._scheduled_probe_at
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -687,3 +1375,112 @@ def test_download_info_to_public_dict_excludes_server_only_fields():
|
|||||||
assert public["url"] == "https://example.com/watch?v=1"
|
assert public["url"] == "https://example.com/watch?v=1"
|
||||||
assert public["title"] == "Test Video"
|
assert public["title"] == "Test Video"
|
||||||
assert public["status"] == "pending"
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_close_releases_status_queue(dq_env):
|
||||||
|
download = _make_download(dq_env)
|
||||||
|
status_queue = MagicMock()
|
||||||
|
proc = MagicMock()
|
||||||
|
download.status_queue = status_queue
|
||||||
|
download.proc = proc
|
||||||
|
|
||||||
|
download.close()
|
||||||
|
|
||||||
|
proc.close.assert_called_once()
|
||||||
|
assert download.status_queue is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_close_releases_status_queue_without_process(dq_env):
|
||||||
|
download = _make_download(dq_env)
|
||||||
|
download.status_queue = MagicMock()
|
||||||
|
|
||||||
|
download.close()
|
||||||
|
|
||||||
|
assert download.status_queue is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_close_releases_status_queue_when_process_close_fails(dq_env):
|
||||||
|
download = _make_download(dq_env)
|
||||||
|
download.status_queue = MagicMock()
|
||||||
|
download.proc = MagicMock()
|
||||||
|
download.proc.close.side_effect = RuntimeError('close failed')
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match='close failed'):
|
||||||
|
download.close()
|
||||||
|
|
||||||
|
assert download.status_queue is None
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
def test_clip_url_t_param_strips_query_and_sets_start(self):
|
||||||
parsed = main.parse_download_options({
|
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",
|
"download_type": "video",
|
||||||
"codec": "auto",
|
"codec": "auto",
|
||||||
"format": "any",
|
"format": "any",
|
||||||
"quality": "best",
|
"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.assertEqual(parsed["clip_start"], 855.0)
|
||||||
self.assertIsNone(parsed["clip_end"])
|
self.assertIsNone(parsed["clip_end"])
|
||||||
|
|
||||||
def test_clip_explicit_start_wins_over_url_t(self):
|
def test_clip_explicit_start_wins_over_url_t(self):
|
||||||
parsed = main.parse_download_options({
|
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",
|
"download_type": "video",
|
||||||
"codec": "auto",
|
"codec": "auto",
|
||||||
"format": "any",
|
"format": "any",
|
||||||
"quality": "best",
|
"quality": "best",
|
||||||
"clip_start": "50",
|
"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.assertEqual(parsed["clip_start"], 50.0)
|
||||||
self.assertIsNone(parsed["clip_end"])
|
self.assertIsNone(parsed["clip_end"])
|
||||||
|
|
||||||
def test_clip_end_only_sets_start_zero_and_strips_url_t(self):
|
def test_clip_end_only_sets_start_zero_and_strips_url_t(self):
|
||||||
parsed = main.parse_download_options({
|
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",
|
"download_type": "video",
|
||||||
"codec": "auto",
|
"codec": "auto",
|
||||||
"format": "any",
|
"format": "any",
|
||||||
"quality": "best",
|
"quality": "best",
|
||||||
"clip_end": "60",
|
"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_start"], 0.0)
|
||||||
self.assertEqual(parsed["clip_end"], 60.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):
|
def test_clip_rejects_end_before_start(self):
|
||||||
with self.assertRaises(main.web.HTTPBadRequest):
|
with self.assertRaises(main.web.HTTPBadRequest):
|
||||||
main.parse_download_options({
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Tests for conservative audio metadata enrichment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from music_metadata import MusicMetadataPreProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess(source_entry, info):
|
||||||
|
processor = MusicMetadataPreProcessor(source_entry=source_entry)
|
||||||
|
_, result = processor.run(info)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_album_uses_existing_order_and_total_when_track_number_is_missing():
|
||||||
|
result = _preprocess(
|
||||||
|
{
|
||||||
|
'playlist_index': '03',
|
||||||
|
'playlist_count': 12,
|
||||||
|
'playlist_title': 'Example Album',
|
||||||
|
},
|
||||||
|
{'title': 'Track', 'album': 'Example Album'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '3/12'
|
||||||
|
assert result['album'] == 'Example Album'
|
||||||
|
|
||||||
|
|
||||||
|
def test_official_track_number_wins_over_album_order():
|
||||||
|
result = _preprocess(
|
||||||
|
{'playlist_index': 3, 'playlist_count': 12},
|
||||||
|
{'track_number': 7, 'album': 'Official Album'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '7/12'
|
||||||
|
assert result['album'] == 'Official Album'
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_official_track_total_is_preserved():
|
||||||
|
result = _preprocess(
|
||||||
|
{'playlist_count': 12},
|
||||||
|
{'track_number': '4/10', 'album': 'Official Album'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '4/10'
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_track_number_and_total_are_retained_from_flat_extraction():
|
||||||
|
result = _preprocess(
|
||||||
|
{'track_number': 2, 'track_count': 9, 'playlist_index': 4},
|
||||||
|
{'title': 'Track'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '2/9'
|
||||||
|
|
||||||
|
|
||||||
|
def test_album_title_falls_back_to_source_playlist_title():
|
||||||
|
result = _preprocess(
|
||||||
|
{'playlist_title': 'Example Album'},
|
||||||
|
{'track_number': 4},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['album'] == 'Example Album'
|
||||||
|
assert result['track_number'] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlist_without_extractor_album_signals_is_not_changed():
|
||||||
|
result = _preprocess(
|
||||||
|
{
|
||||||
|
'playlist_index': 3,
|
||||||
|
'playlist_count': 12,
|
||||||
|
'playlist_title': 'Example Playlist',
|
||||||
|
},
|
||||||
|
{'title': 'Track'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'album' not in result
|
||||||
|
assert 'track_number' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_regular_video_artwork_is_not_changed():
|
||||||
|
thumbnails = [
|
||||||
|
{'url': 'square.jpg', 'width': 500, 'height': 500},
|
||||||
|
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
|
||||||
|
]
|
||||||
|
result = _preprocess({}, {'title': 'Regular Video', 'thumbnails': thumbnails.copy()})
|
||||||
|
|
||||||
|
assert result['thumbnails'] == thumbnails
|
||||||
|
assert 'thumbnail' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_audio_prefers_largest_existing_square_thumbnail():
|
||||||
|
result = _preprocess(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
'track': 'Track',
|
||||||
|
'thumbnails': [
|
||||||
|
{'url': 'small-square.jpg', 'width': 200, 'height': 200},
|
||||||
|
{'url': 'large-square.jpg', 'width': 1000, 'height': 1000},
|
||||||
|
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['thumbnails'][-1]['url'] == 'large-square.jpg'
|
||||||
|
assert result['thumbnail'] == 'large-square.jpg'
|
||||||
|
|
||||||
|
|
||||||
|
def test_landscape_only_music_artwork_keeps_existing_order():
|
||||||
|
thumbnails = [
|
||||||
|
{'url': 'small.jpg', 'width': 640, 'height': 360},
|
||||||
|
{'url': 'large.jpg', 'width': 1280, 'height': 720},
|
||||||
|
]
|
||||||
|
result = _preprocess(
|
||||||
|
{},
|
||||||
|
{'track': 'Track', 'thumbnails': thumbnails.copy()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['thumbnails'] == thumbnails
|
||||||
|
assert 'thumbnail' not in result
|
||||||
@@ -146,12 +146,12 @@ class PersistentQueueTests(unittest.TestCase):
|
|||||||
self.assertNotIn("formats", record["entry"])
|
self.assertNotIn("formats", record["entry"])
|
||||||
self.assertNotIn("description", record["entry"])
|
self.assertNotIn("description", record["entry"])
|
||||||
|
|
||||||
def test_completed_queue_does_not_persist_entry_or_transient_progress(self):
|
def test_completed_queue_persists_only_failed_retry_context(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = os.path.join(tmp, "completed")
|
path = os.path.join(tmp, "completed")
|
||||||
pq = PersistentQueue("completed", path)
|
pq = PersistentQueue("completed", path)
|
||||||
info = _make_info("http://done.example")
|
info = _make_info("http://done.example")
|
||||||
info.status = "finished"
|
info.status = "error"
|
||||||
info.percent = 88
|
info.percent = 88
|
||||||
info.speed = 123
|
info.speed = 123
|
||||||
info.eta = 9
|
info.eta = 9
|
||||||
@@ -167,12 +167,24 @@ class PersistentQueueTests(unittest.TestCase):
|
|||||||
payload = json.load(f)
|
payload = json.load(f)
|
||||||
|
|
||||||
record = payload["items"][0]["info"]
|
record = payload["items"][0]["info"]
|
||||||
self.assertNotIn("entry", record)
|
self.assertEqual(
|
||||||
|
record["entry"],
|
||||||
|
{
|
||||||
|
"playlist_index": "01",
|
||||||
|
"playlist_title": "Playlist",
|
||||||
|
},
|
||||||
|
)
|
||||||
self.assertNotIn("percent", record)
|
self.assertNotIn("percent", record)
|
||||||
self.assertNotIn("speed", record)
|
self.assertNotIn("speed", record)
|
||||||
self.assertNotIn("eta", record)
|
self.assertNotIn("eta", record)
|
||||||
self.assertEqual(record["filename"], "done.mp4")
|
self.assertEqual(record["filename"], "done.mp4")
|
||||||
|
|
||||||
|
info.status = "finished"
|
||||||
|
pq.put(_FakeDownload(info))
|
||||||
|
with open(path + ".json", encoding="utf-8") as f:
|
||||||
|
payload = json.load(f)
|
||||||
|
self.assertNotIn("entry", payload["items"][0]["info"])
|
||||||
|
|
||||||
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
|
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = os.path.join(tmp, "queue")
|
path = os.path.join(tmp, "queue")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
|
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
|
||||||
|
|
||||||
@@ -21,6 +22,135 @@ class StateStoreTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload["schema_version"], 2)
|
self.assertEqual(payload["schema_version"], 2)
|
||||||
self.assertEqual(payload["items"][0]["info"]["title"], "hello")
|
self.assertEqual(payload["items"][0]["info"]["title"], "hello")
|
||||||
|
|
||||||
|
def test_save_falls_back_to_direct_write_when_mkstemp_fails(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||||
|
|
||||||
|
with self.assertLogs("state_store", level="WARNING") as logs:
|
||||||
|
with patch(
|
||||||
|
"state_store.tempfile.mkstemp",
|
||||||
|
side_effect=PermissionError(1, "Operation not permitted"),
|
||||||
|
):
|
||||||
|
store.save({"items": [{"key": "a"}]})
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
self.assertTrue(any(path in message for message in logs.output))
|
||||||
|
# Fallback keeps owner-only permissions, matching the atomic path.
|
||||||
|
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
||||||
|
payload = store.load()
|
||||||
|
self.assertEqual(payload["items"], [{"key": "a"}])
|
||||||
|
|
||||||
|
def test_fallback_tightens_permissions_on_existing_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("{}")
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
|
||||||
|
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||||
|
with patch(
|
||||||
|
"state_store.tempfile.mkstemp",
|
||||||
|
side_effect=PermissionError(1, "Operation not permitted"),
|
||||||
|
):
|
||||||
|
store.save({"items": [{"key": "a"}]})
|
||||||
|
|
||||||
|
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
||||||
|
self.assertEqual(store.load()["items"], [{"key": "a"}])
|
||||||
|
|
||||||
|
def test_save_falls_back_to_direct_write_when_replace_fails(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"state_store.os.replace",
|
||||||
|
side_effect=PermissionError(1, "Operation not permitted"),
|
||||||
|
):
|
||||||
|
store.save({"items": [{"key": "a"}]})
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
payload = store.load()
|
||||||
|
self.assertEqual(payload["items"], [{"key": "a"}])
|
||||||
|
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
|
||||||
|
|
||||||
|
def test_save_reraises_when_atomic_and_direct_write_fail(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"state_store.tempfile.mkstemp",
|
||||||
|
side_effect=PermissionError(1, "Operation not permitted"),
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"state_store.os.open",
|
||||||
|
side_effect=PermissionError(13, "Permission denied"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
|
store.save({"items": [{"key": "a"}]})
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.errno, 13)
|
||||||
|
self.assertFalse(os.path.exists(path))
|
||||||
|
|
||||||
|
def test_unsupported_fsync_keeps_atomic_path(self):
|
||||||
|
# fsync being unsupported (EINVAL/ENOSYS) must not by itself trigger the
|
||||||
|
# direct-write fallback; the atomic temp-file + rename path still runs.
|
||||||
|
import errno as _errno
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"state_store.os.fsync",
|
||||||
|
side_effect=OSError(_errno.EINVAL, "Invalid argument"),
|
||||||
|
):
|
||||||
|
with self.assertNoLogs("state_store", level="WARNING"):
|
||||||
|
store.save({"items": [{"key": "a"}]})
|
||||||
|
|
||||||
|
self.assertEqual(store.load()["items"], [{"key": "a"}])
|
||||||
|
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
|
||||||
|
|
||||||
|
def test_save_reraises_and_preserves_state_on_non_atomic_errno(self):
|
||||||
|
# A storage failure such as ENOSPC is not an "atomic unavailable"
|
||||||
|
# signal, so it must surface instead of falling back to a direct write
|
||||||
|
# that would truncate the existing good state file.
|
||||||
|
import errno as _errno
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||||
|
store.save({"items": [{"key": "good"}]})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"state_store.tempfile.mkstemp",
|
||||||
|
side_effect=OSError(_errno.ENOSPC, "No space left on device"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(OSError) as ctx:
|
||||||
|
store.save({"items": [{"key": "new"}]})
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.errno, _errno.ENOSPC)
|
||||||
|
# Existing state is untouched.
|
||||||
|
self.assertEqual(store.load()["items"], [{"key": "good"}])
|
||||||
|
|
||||||
|
def test_serialization_failure_preserves_existing_state(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||||
|
store.save({"items": [{"key": "good"}]})
|
||||||
|
|
||||||
|
# Even on the fallback path, a non-serializable payload must raise
|
||||||
|
# before the existing good state file is touched.
|
||||||
|
with patch(
|
||||||
|
"state_store.tempfile.mkstemp",
|
||||||
|
side_effect=PermissionError(1, "Operation not permitted"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
store.save({"items": object()})
|
||||||
|
|
||||||
|
self.assertEqual(store.load()["items"], [{"key": "good"}])
|
||||||
|
|
||||||
def test_invalid_file_is_quarantined(self):
|
def test_invalid_file_is_quarantined(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = os.path.join(tmp, "queue.json")
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shelve
|
import shelve
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
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)
|
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
||||||
|
|
||||||
from subscriptions import (
|
from subscriptions import (
|
||||||
|
SubscriptionInfo,
|
||||||
SubscriptionManager,
|
SubscriptionManager,
|
||||||
_is_subscriber_only_entry,
|
_is_subscriber_only_entry,
|
||||||
coerce_optional_bool,
|
coerce_optional_bool,
|
||||||
@@ -44,6 +47,7 @@ class _Config:
|
|||||||
self.DOWNLOAD_DIR = state_dir
|
self.DOWNLOAD_DIR = state_dir
|
||||||
self.TEMP_DIR = state_dir
|
self.TEMP_DIR = state_dir
|
||||||
self.YTDL_OPTIONS = {}
|
self.YTDL_OPTIONS = {}
|
||||||
|
self.YTDL_OPTIONS_PRESETS = {}
|
||||||
|
|
||||||
|
|
||||||
class _Queue:
|
class _Queue:
|
||||||
@@ -405,6 +409,139 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
|
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
|
||||||
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
|
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
|
||||||
|
|
||||||
|
async def test_check_now_applies_subscription_clip_bounds(self):
|
||||||
|
"""Issue #1049: clip bounds were the one download option a subscription
|
||||||
|
could not carry, so they must reach every entry it queues."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
queue = _Queue()
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"subscriptions.extract_flat_playlist",
|
||||||
|
side_effect=[
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[
|
||||||
|
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
|
||||||
|
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
result = await mgr.add_subscription(
|
||||||
|
"https://example.com/channel",
|
||||||
|
check_interval_minutes=60,
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
quality="best",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
auto_start=True,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
clip_start=30.0,
|
||||||
|
clip_end=90.0,
|
||||||
|
)
|
||||||
|
sub_id = result["subscription"]["id"]
|
||||||
|
self.assertEqual(mgr.get(sub_id).clip_start, 30.0)
|
||||||
|
self.assertEqual(mgr.get(sub_id).clip_end, 90.0)
|
||||||
|
await mgr.check_now([sub_id])
|
||||||
|
|
||||||
|
self.assertEqual(len(queue.entries), 1)
|
||||||
|
_entry, args, _kwargs = queue.entries[0]
|
||||||
|
# add_entry(entry, download_type, ..., ytdl_options_overrides, clip_start, clip_end)
|
||||||
|
self.assertEqual(args[-2], 30.0)
|
||||||
|
self.assertEqual(args[-1], 90.0)
|
||||||
|
|
||||||
|
async def test_clip_bounds_survive_reload_and_default_to_none(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg = _Config(tmp)
|
||||||
|
queue = _Queue()
|
||||||
|
mgr = SubscriptionManager(cfg, queue, _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
# Records written before these fields existed simply take the defaults.
|
||||||
|
self.assertIsNone(mgr.get(sub_id).clip_start)
|
||||||
|
self.assertIsNone(mgr.get(sub_id).clip_end)
|
||||||
|
|
||||||
|
mgr.get(sub_id).clip_start = 12.5
|
||||||
|
async with mgr._lock:
|
||||||
|
mgr._save_locked()
|
||||||
|
|
||||||
|
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||||
|
self.assertEqual(reloaded.get(sub_id).clip_start, 12.5)
|
||||||
|
self.assertIsNone(reloaded.get(sub_id).clip_end)
|
||||||
|
|
||||||
|
async def test_check_now_applies_subscription_sponsorblock(self):
|
||||||
|
"""Subscriptions download unattended, so the sponsor-segment removal has
|
||||||
|
to reach every entry the subscription queues, not just manual adds."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
queue = _Queue()
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"subscriptions.extract_flat_playlist",
|
||||||
|
side_effect=[
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[
|
||||||
|
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
|
||||||
|
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
result = await mgr.add_subscription(
|
||||||
|
"https://example.com/channel",
|
||||||
|
check_interval_minutes=60,
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
quality="best",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
auto_start=True,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
sponsorblock=True,
|
||||||
|
)
|
||||||
|
sub_id = result["subscription"]["id"]
|
||||||
|
self.assertTrue(mgr.get(sub_id).sponsorblock)
|
||||||
|
await mgr.check_now([sub_id])
|
||||||
|
|
||||||
|
self.assertEqual(len(queue.entries), 1)
|
||||||
|
_entry, _args, kwargs = queue.entries[0]
|
||||||
|
self.assertIs(kwargs["sponsorblock"], True)
|
||||||
|
|
||||||
|
async def test_sponsorblock_survives_reload_and_defaults_to_false(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg = _Config(tmp)
|
||||||
|
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
# Records written before the field existed simply take the default.
|
||||||
|
self.assertFalse(mgr.get(sub_id).sponsorblock)
|
||||||
|
|
||||||
|
mgr.get(sub_id).sponsorblock = True
|
||||||
|
async with mgr._lock:
|
||||||
|
mgr._save_locked()
|
||||||
|
|
||||||
|
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||||
|
self.assertTrue(reloaded.get(sub_id).sponsorblock)
|
||||||
|
|
||||||
async def test_check_now_queues_subscriber_only_when_skip_disabled(self):
|
async def test_check_now_queues_subscriber_only_when_skip_disabled(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
queue = _Queue()
|
queue = _Queue()
|
||||||
@@ -571,8 +708,16 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
sub_id = result["subscription"]["id"]
|
sub_id = result["subscription"]["id"]
|
||||||
with self.assertRaises(ValueError):
|
update_result = await mgr.update_subscription(sub_id, {"enabled": "maybe"})
|
||||||
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):
|
async def test_add_subscription_rejects_invalid_title_regex(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -809,6 +954,145 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(upd["subscription"]["title_regex"], "foo|bar")
|
self.assertEqual(upd["subscription"]["title_regex"], "foo|bar")
|
||||||
self.assertEqual(mgr.list_all()[0].title_regex, "foo|bar")
|
self.assertEqual(mgr.list_all()[0].title_regex, "foo|bar")
|
||||||
|
|
||||||
|
async def _add_one_subscription(self, mgr):
|
||||||
|
with patch(
|
||||||
|
"subscriptions.extract_flat_playlist",
|
||||||
|
return_value=(
|
||||||
|
{"_type": "channel", "title": "Videos"},
|
||||||
|
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await mgr.add_subscription(
|
||||||
|
"https://example.com/playlist?list=UULFabc",
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
return result["subscription"]["id"]
|
||||||
|
|
||||||
|
async def test_update_subscription_renames(self):
|
||||||
|
"""Issue #1044: UULF-style uploads playlists all come back named 'Videos',
|
||||||
|
so the user needs to be able to relabel them."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
self.assertEqual(mgr.list_all()[0].name, "Videos")
|
||||||
|
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"name": " Jane's uploads \n"})
|
||||||
|
self.assertEqual(upd["status"], "ok")
|
||||||
|
# Surrounding and interior whitespace is collapsed to keep the name
|
||||||
|
# a single-line label.
|
||||||
|
self.assertEqual(upd["subscription"]["name"], "Jane's uploads")
|
||||||
|
self.assertEqual(mgr.list_all()[0].name, "Jane's uploads")
|
||||||
|
|
||||||
|
async def test_update_subscription_rename_survives_reload(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg = _Config(tmp)
|
||||||
|
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
await mgr.update_subscription(sub_id, {"name": "Renamed"})
|
||||||
|
|
||||||
|
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||||
|
self.assertEqual(reloaded.get(sub_id).name, "Renamed")
|
||||||
|
|
||||||
|
async def test_update_subscription_rejects_unusable_name(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
|
||||||
|
for bad in ("", " ", "\n\t", 42, None, ["a"], "x" * 201):
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"name": bad})
|
||||||
|
self.assertEqual(upd["status"], "error", f"expected {bad!r} to be rejected")
|
||||||
|
self.assertEqual(mgr.list_all()[0].name, "Videos")
|
||||||
|
|
||||||
|
async def test_update_subscription_accepts_name_at_length_limit(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"name": "x" * 200})
|
||||||
|
self.assertEqual(upd["status"], "ok")
|
||||||
|
self.assertEqual(mgr.list_all()[0].name, "x" * 200)
|
||||||
|
|
||||||
|
async def test_update_subscription_changes_folder(self):
|
||||||
|
"""Issue #1052: the folder was settable at creation and then frozen,
|
||||||
|
because it was never added to the fields the update route accepts."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
self.assertEqual(mgr.list_all()[0].folder, "")
|
||||||
|
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"folder": " channels/jane "})
|
||||||
|
self.assertEqual(upd["status"], "ok")
|
||||||
|
self.assertEqual(upd["subscription"]["folder"], "channels/jane")
|
||||||
|
self.assertEqual(mgr.list_all()[0].folder, "channels/jane")
|
||||||
|
|
||||||
|
async def test_update_subscription_folder_survives_reload(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg = _Config(tmp)
|
||||||
|
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
await mgr.update_subscription(sub_id, {"folder": "archive"})
|
||||||
|
|
||||||
|
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||||
|
self.assertEqual(reloaded.get(sub_id).folder, "archive")
|
||||||
|
|
||||||
|
async def test_update_subscription_clears_folder(self):
|
||||||
|
# An empty folder is valid and means the base download directory.
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
await mgr.update_subscription(sub_id, {"folder": "archive"})
|
||||||
|
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"folder": " "})
|
||||||
|
self.assertEqual(upd["status"], "ok")
|
||||||
|
self.assertEqual(mgr.list_all()[0].folder, "")
|
||||||
|
|
||||||
|
async def test_update_subscription_rejects_unusable_folder(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
await mgr.update_subscription(sub_id, {"folder": "keep"})
|
||||||
|
|
||||||
|
bad_values = (
|
||||||
|
"/etc",
|
||||||
|
"/absolute/path",
|
||||||
|
"../escape",
|
||||||
|
"nested/../../escape",
|
||||||
|
"windows\\..\\escape",
|
||||||
|
42,
|
||||||
|
["a"],
|
||||||
|
)
|
||||||
|
for bad in bad_values:
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"folder": bad})
|
||||||
|
self.assertEqual(upd["status"], "error", f"expected {bad!r} to be rejected")
|
||||||
|
self.assertEqual(mgr.list_all()[0].folder, "keep")
|
||||||
|
|
||||||
|
async def test_update_subscription_folder_leaves_other_fields_alone(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
sub_id = await self._add_one_subscription(mgr)
|
||||||
|
before = mgr.get(sub_id)
|
||||||
|
name, interval, enabled = before.name, before.check_interval_minutes, before.enabled
|
||||||
|
|
||||||
|
await mgr.update_subscription(sub_id, {"folder": "only/this"})
|
||||||
|
|
||||||
|
after = mgr.get(sub_id)
|
||||||
|
self.assertEqual(after.folder, "only/this")
|
||||||
|
self.assertEqual(after.name, name)
|
||||||
|
self.assertEqual(after.check_interval_minutes, interval)
|
||||||
|
self.assertEqual(after.enabled, enabled)
|
||||||
|
|
||||||
async def test_update_subscription_skip_subscriber_only(self):
|
async def test_update_subscription_skip_subscriber_only(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
queue = _Queue()
|
queue = _Queue()
|
||||||
@@ -1012,6 +1296,261 @@ class ExtractFlatPlaylistTests(unittest.TestCase):
|
|||||||
self.assertEqual(info.get("_type"), "playlist")
|
self.assertEqual(info.get("_type"), "playlist")
|
||||||
self.assertEqual([entry["webpage_url"] for entry in entries], ["https://example.com/v1"])
|
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_scan_never_writes_playlist_sidecar_files(self):
|
||||||
|
"""A subscription scan is a metadata probe. yt-dlp writes the
|
||||||
|
playlist-level infojson/description/thumbnail regardless of ``download``,
|
||||||
|
so without this a writeinfojson/writethumbnail user would get stray files
|
||||||
|
in DOWNLOAD_DIR on every check interval. Issue #1040."""
|
||||||
|
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 = {"writeinfojson": True, "writethumbnail": True}
|
||||||
|
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_overrides={"allow_playlist_files": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(captured_params)
|
||||||
|
self.assertIs(captured_params[0].get("allow_playlist_files"), False)
|
||||||
|
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Tests for the SSRF URL guard (``url_guard.validate_url``)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import url_guard
|
||||||
|
from url_guard import (
|
||||||
|
validate_url,
|
||||||
|
_address_allowed_at_connect,
|
||||||
|
_address_is_global,
|
||||||
|
_guarded_getaddrinfo,
|
||||||
|
_url_endpoint,
|
||||||
|
install_socket_guard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _addrinfo(*addrs, family=socket.AF_INET):
|
||||||
|
return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (addr, 0)) for addr in addrs]
|
||||||
|
|
||||||
|
|
||||||
|
class NonUrlInputTests(unittest.TestCase):
|
||||||
|
"""Bare IDs and yt-dlp search/extractor prefixes must pass untouched."""
|
||||||
|
|
||||||
|
def test_bare_video_id_allowed(self):
|
||||||
|
self.assertIsNone(validate_url("dQw4w9WgXcQ"))
|
||||||
|
|
||||||
|
def test_ytsearch_prefix_allowed(self):
|
||||||
|
self.assertIsNone(validate_url("ytsearch:some song"))
|
||||||
|
|
||||||
|
def test_empty_string_allowed(self):
|
||||||
|
self.assertIsNone(validate_url(""))
|
||||||
|
|
||||||
|
def test_non_string_rejected(self):
|
||||||
|
self.assertIsNotNone(validate_url(None))
|
||||||
|
|
||||||
|
|
||||||
|
class SchemeTests(unittest.TestCase):
|
||||||
|
def test_file_scheme_blocked(self):
|
||||||
|
self.assertIsNotNone(validate_url("file:///etc/passwd"))
|
||||||
|
|
||||||
|
def test_ftp_scheme_blocked(self):
|
||||||
|
self.assertIsNotNone(validate_url("ftp://example.com/x"))
|
||||||
|
|
||||||
|
def test_data_scheme_blocked(self):
|
||||||
|
self.assertIsNotNone(validate_url("data://text/plain;base64,AAAA"))
|
||||||
|
|
||||||
|
|
||||||
|
class HostnameBlocklistTests(unittest.TestCase):
|
||||||
|
def test_localhost_blocked_without_lookup(self):
|
||||||
|
with mock.patch("url_guard.socket.getaddrinfo") as gai:
|
||||||
|
self.assertIsNotNone(validate_url("http://localhost:8080/x"))
|
||||||
|
gai.assert_not_called()
|
||||||
|
|
||||||
|
def test_localhost_subdomain_blocked(self):
|
||||||
|
self.assertIsNotNone(validate_url("http://foo.localhost/x"))
|
||||||
|
|
||||||
|
def test_gcp_metadata_name_blocked(self):
|
||||||
|
self.assertIsNotNone(validate_url("http://metadata.google.internal/x"))
|
||||||
|
|
||||||
|
|
||||||
|
class AddressResolutionTests(unittest.TestCase):
|
||||||
|
def _validate_with_addrs(self, url, *addrs, family=socket.AF_INET):
|
||||||
|
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo(*addrs, family=family)):
|
||||||
|
return validate_url(url)
|
||||||
|
|
||||||
|
def test_public_https_allowed(self):
|
||||||
|
self.assertIsNone(self._validate_with_addrs("https://youtube.com/watch?v=x", "142.250.1.1"))
|
||||||
|
|
||||||
|
def test_public_http_allowed(self):
|
||||||
|
self.assertIsNone(self._validate_with_addrs("http://example.com/x", "93.184.216.34"))
|
||||||
|
|
||||||
|
def test_link_local_metadata_blocked(self):
|
||||||
|
self.assertIsNotNone(self._validate_with_addrs("http://metadata/x", "169.254.169.254"))
|
||||||
|
|
||||||
|
def test_loopback_ipv4_blocked(self):
|
||||||
|
self.assertIsNotNone(self._validate_with_addrs("http://127.0.0.1/x", "127.0.0.1"))
|
||||||
|
|
||||||
|
def test_private_rfc1918_blocked(self):
|
||||||
|
self.assertIsNotNone(self._validate_with_addrs("http://intranet/x", "10.0.0.5"))
|
||||||
|
|
||||||
|
def test_decimal_ip_form_blocked(self):
|
||||||
|
# 2852039166 == 169.254.169.254; the OS resolver normalizes it.
|
||||||
|
self.assertIsNotNone(self._validate_with_addrs("http://2852039166/x", "169.254.169.254"))
|
||||||
|
|
||||||
|
def test_ipv6_loopback_blocked(self):
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self._validate_with_addrs("http://[::1]/x", "::1", family=socket.AF_INET6)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ipv4_mapped_ipv6_metadata_blocked(self):
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self._validate_with_addrs(
|
||||||
|
"http://evil/x", "::ffff:169.254.169.254", family=socket.AF_INET6
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mixed_public_and_private_blocked(self):
|
||||||
|
# If any resolved address is internal, reject the whole URL.
|
||||||
|
self.assertIsNotNone(self._validate_with_addrs("http://mixed/x", "142.250.1.1", "127.0.0.1"))
|
||||||
|
|
||||||
|
def test_resolution_failure_is_rejected(self):
|
||||||
|
# Fail closed: an unresolvable host cannot be verified as non-internal.
|
||||||
|
with mock.patch("url_guard.socket.getaddrinfo", side_effect=socket.gaierror):
|
||||||
|
self.assertIsNotNone(validate_url("http://does-not-resolve.example/x"))
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectAddressPolicyTests(unittest.TestCase):
|
||||||
|
"""Connect-time policy: allow global, plus anything at a destination the
|
||||||
|
caller has established is the operator's configured proxy."""
|
||||||
|
|
||||||
|
def test_global_allowed(self):
|
||||||
|
self.assertTrue(_address_allowed_at_connect("142.250.1.1"))
|
||||||
|
|
||||||
|
def test_loopback_blocked_by_default(self):
|
||||||
|
# A blanket loopback allowance is what let manifest-derived media URLs
|
||||||
|
# reach services on the server's own loopback interface.
|
||||||
|
self.assertFalse(_address_allowed_at_connect("127.0.0.1"))
|
||||||
|
self.assertFalse(_address_allowed_at_connect("::1"))
|
||||||
|
|
||||||
|
def test_loopback_allowed_only_when_opted_in(self):
|
||||||
|
self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_allowed_endpoint=True))
|
||||||
|
self.assertTrue(_address_allowed_at_connect("::1", is_allowed_endpoint=True))
|
||||||
|
|
||||||
|
def test_proxy_opt_in_covers_any_internal_range(self):
|
||||||
|
# A proxy is just as legitimately on the LAN or a VPN range as on
|
||||||
|
# loopback (#1055): the allowance follows the operator's configured
|
||||||
|
# endpoint, not a particular address family.
|
||||||
|
self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_allowed_endpoint=True))
|
||||||
|
self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_allowed_endpoint=True))
|
||||||
|
self.assertTrue(_address_allowed_at_connect("fd00::1", is_allowed_endpoint=True))
|
||||||
|
|
||||||
|
def test_opt_in_still_rejects_non_addresses(self):
|
||||||
|
self.assertFalse(_address_allowed_at_connect("not-an-ip", is_allowed_endpoint=True))
|
||||||
|
|
||||||
|
def test_link_local_metadata_blocked(self):
|
||||||
|
self.assertFalse(_address_allowed_at_connect("169.254.169.254"))
|
||||||
|
|
||||||
|
def test_private_blocked(self):
|
||||||
|
self.assertFalse(_address_allowed_at_connect("10.0.0.5"))
|
||||||
|
self.assertFalse(_address_allowed_at_connect("192.168.1.10"))
|
||||||
|
|
||||||
|
def test_ipv4_mapped_metadata_blocked(self):
|
||||||
|
self.assertFalse(_address_allowed_at_connect("::ffff:169.254.169.254"))
|
||||||
|
|
||||||
|
|
||||||
|
class TunnelledIPv4Tests(unittest.TestCase):
|
||||||
|
"""IPv6 transition forms that carry an IPv4 address the outer address hides.
|
||||||
|
|
||||||
|
``is_global`` looks only at the outer address, so a form that tunnels an
|
||||||
|
internal IPv4 has to be unwrapped before it is judged (GHSA-5mq5-qr7m-f4wx).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_nat64_well_known_prefix_blocked(self):
|
||||||
|
# 2000::/3 global unicast on its face; carries the metadata address.
|
||||||
|
self.assertFalse(_address_is_global("64:ff9b::a9fe:a9fe"))
|
||||||
|
self.assertFalse(_address_is_global("64:ff9b::7f00:1"))
|
||||||
|
self.assertFalse(_address_allowed_at_connect("64:ff9b::a9fe:a9fe"))
|
||||||
|
|
||||||
|
def test_nat64_carrying_a_public_address_allowed(self):
|
||||||
|
self.assertTrue(_address_is_global("64:ff9b::8.8.8.8"))
|
||||||
|
|
||||||
|
def test_ipv4_compatible_form_blocked(self):
|
||||||
|
# The deprecated ::/96 form, likewise global-looking to is_global.
|
||||||
|
self.assertFalse(_address_is_global("::a9fe:a9fe"))
|
||||||
|
self.assertFalse(_address_allowed_at_connect("::a9fe:a9fe"))
|
||||||
|
|
||||||
|
def test_sixtofour_and_teredo_stay_blocked(self):
|
||||||
|
# Python rejects these ranges wholesale. Unwrapping must not promote a
|
||||||
|
# blocked address to an allowed one just because the payload is global.
|
||||||
|
self.assertFalse(_address_is_global("2002:a9fe:a9fe::"))
|
||||||
|
self.assertFalse(_address_is_global("2002:0808:0808::"))
|
||||||
|
self.assertFalse(_address_is_global("2001:0:4136:e378:8000:63bf:3fff:fdd2"))
|
||||||
|
|
||||||
|
def test_plain_addresses_unaffected(self):
|
||||||
|
self.assertTrue(_address_is_global("142.250.1.1"))
|
||||||
|
self.assertTrue(_address_is_global("2607:f8b0:4004:c07::64"))
|
||||||
|
self.assertFalse(_address_is_global("not-an-ip"))
|
||||||
|
|
||||||
|
def test_tunnelled_form_blocked_at_ingress(self):
|
||||||
|
with mock.patch(
|
||||||
|
"url_guard.socket.getaddrinfo",
|
||||||
|
return_value=_addrinfo("64:ff9b::a9fe:a9fe", family=socket.AF_INET6),
|
||||||
|
):
|
||||||
|
self.assertIsNotNone(validate_url("http://nat64.example/x"))
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointParsingTests(unittest.TestCase):
|
||||||
|
def test_explicit_port(self):
|
||||||
|
self.assertEqual(_url_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
|
||||||
|
|
||||||
|
def test_default_port_per_scheme(self):
|
||||||
|
self.assertEqual(_url_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080))
|
||||||
|
self.assertEqual(_url_endpoint("http://127.0.0.1"), ("127.0.0.1", 80))
|
||||||
|
|
||||||
|
def test_bare_host_port(self):
|
||||||
|
self.assertEqual(_url_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080))
|
||||||
|
|
||||||
|
def test_hostname_lowercased(self):
|
||||||
|
self.assertEqual(_url_endpoint("http://LocalHost.:9050"), ("localhost", 9050))
|
||||||
|
|
||||||
|
def test_ipv6_literal(self):
|
||||||
|
self.assertEqual(_url_endpoint("http://[::1]:9050"), ("::1", 9050))
|
||||||
|
|
||||||
|
def test_empty_and_invalid(self):
|
||||||
|
self.assertIsNone(_url_endpoint(""))
|
||||||
|
self.assertIsNone(_url_endpoint(" "))
|
||||||
|
self.assertIsNone(_url_endpoint(None))
|
||||||
|
self.assertIsNone(_url_endpoint("http://"))
|
||||||
|
|
||||||
|
|
||||||
|
class GuardedGetaddrinfoTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Default state: no proxy configured, so no loopback destination allowed.
|
||||||
|
saved = set(url_guard._allowed_endpoints)
|
||||||
|
url_guard._allowed_endpoints = set()
|
||||||
|
self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
|
||||||
|
|
||||||
|
def test_internal_only_raises(self):
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")):
|
||||||
|
with self.assertRaises(socket.gaierror):
|
||||||
|
_guarded_getaddrinfo("metadata", 80)
|
||||||
|
|
||||||
|
def test_filters_internal_keeps_global(self):
|
||||||
|
# Split-horizon rebinding: keep the public address, drop the internal one.
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("142.250.1.1", "10.0.0.1")):
|
||||||
|
results = _guarded_getaddrinfo("mixed", 80)
|
||||||
|
self.assertEqual([r[4][0] for r in results], ["142.250.1.1"])
|
||||||
|
|
||||||
|
def test_loopback_blocked_without_matching_proxy(self):
|
||||||
|
# The advisory case: an m3u8 segment URL pointing at a loopback service.
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||||
|
with self.assertRaises(socket.gaierror):
|
||||||
|
_guarded_getaddrinfo("127.0.0.1", 9999)
|
||||||
|
|
||||||
|
def test_loopback_allowed_at_configured_url_endpoint(self):
|
||||||
|
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||||
|
results = _guarded_getaddrinfo("127.0.0.1", 9050)
|
||||||
|
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||||
|
|
||||||
|
def test_loopback_blocked_at_other_port_on_proxy_host(self):
|
||||||
|
# Same host as the proxy, different port: still off limits.
|
||||||
|
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||||
|
with self.assertRaises(socket.gaierror):
|
||||||
|
_guarded_getaddrinfo("127.0.0.1", 9999)
|
||||||
|
|
||||||
|
def test_proxy_reachable_by_hostname(self):
|
||||||
|
url_guard._allowed_endpoints = {("localhost", 9050)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||||
|
results = _guarded_getaddrinfo("localhost", 9050)
|
||||||
|
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||||
|
|
||||||
|
def test_string_port_is_normalised(self):
|
||||||
|
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||||
|
results = _guarded_getaddrinfo("127.0.0.1", "9050")
|
||||||
|
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||||
|
|
||||||
|
def test_lan_proxy_reachable(self):
|
||||||
|
# #1055: a socks5 proxy on the LAN, refused while the allowance was
|
||||||
|
# loopback-only, which pushed operators to ALLOW_PRIVATE_ADDRESSES.
|
||||||
|
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
||||||
|
results = _guarded_getaddrinfo("10.1.20.30", 1080)
|
||||||
|
self.assertEqual([r[4][0] for r in results], ["10.1.20.30"])
|
||||||
|
|
||||||
|
def test_other_lan_host_still_blocked(self):
|
||||||
|
# The allowance is the proxy's endpoint, not its subnet.
|
||||||
|
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.31")):
|
||||||
|
with self.assertRaises(socket.gaierror):
|
||||||
|
_guarded_getaddrinfo("10.1.20.31", 1080)
|
||||||
|
|
||||||
|
def test_pot_provider_reachable_on_loopback(self):
|
||||||
|
# #1064: the bundled PO token provider listens on loopback, and blocking
|
||||||
|
# it left every default install downloading YouTube without a token.
|
||||||
|
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||||
|
results = _guarded_getaddrinfo("127.0.0.1", 4416)
|
||||||
|
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||||
|
|
||||||
|
def test_other_loopback_service_still_blocked(self):
|
||||||
|
# MeTube's own port is one hop away on the same interface: allowing the
|
||||||
|
# token provider must not allow the rest of loopback.
|
||||||
|
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||||
|
with self.assertRaises(socket.gaierror):
|
||||||
|
_guarded_getaddrinfo("127.0.0.1", 8081)
|
||||||
|
|
||||||
|
def test_proxy_address_not_borrowable_by_another_host(self):
|
||||||
|
# Matching is on the configured host string: a manifest URL that resolves
|
||||||
|
# to the proxy's address under its own name gets no allowance.
|
||||||
|
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
|
||||||
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
||||||
|
with self.assertRaises(socket.gaierror):
|
||||||
|
_guarded_getaddrinfo("evil.example", 1080)
|
||||||
|
|
||||||
|
|
||||||
|
class AllowPrivateBypassTests(unittest.TestCase):
|
||||||
|
"""ALLOW_PRIVATE_ADDRESSES: trusted proxy/VPN environments opt out of the
|
||||||
|
SSRF address checks (e.g. Fake-IP clients that resolve to 198.18.0.0/15)."""
|
||||||
|
|
||||||
|
def test_internal_address_allowed_when_bypassed(self):
|
||||||
|
# Fake-IP benchmarking range that is_global rejects by default.
|
||||||
|
self.assertIsNone(validate_url("http://www.youtube.com/x", allow_private=True))
|
||||||
|
|
||||||
|
def test_private_host_allowed_when_bypassed(self):
|
||||||
|
# No DNS lookup needed: the bypass returns before resolution.
|
||||||
|
with mock.patch("url_guard.socket.getaddrinfo") as gai:
|
||||||
|
self.assertIsNone(validate_url("http://192.168.1.1/x", allow_private=True))
|
||||||
|
gai.assert_not_called()
|
||||||
|
|
||||||
|
def test_scheme_still_enforced_when_bypassed(self):
|
||||||
|
self.assertIsNotNone(validate_url("file:///etc/passwd", allow_private=True))
|
||||||
|
|
||||||
|
def test_socket_guard_not_installed_when_bypassed(self):
|
||||||
|
original = socket.getaddrinfo
|
||||||
|
try:
|
||||||
|
install_socket_guard(allow_private=True)
|
||||||
|
self.assertIs(socket.getaddrinfo, original)
|
||||||
|
finally:
|
||||||
|
socket.getaddrinfo = original
|
||||||
|
|
||||||
|
|
||||||
|
class InstallSocketGuardTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
original, saved = socket.getaddrinfo, set(url_guard._allowed_endpoints)
|
||||||
|
self.addCleanup(lambda: setattr(socket, "getaddrinfo", original))
|
||||||
|
self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
|
||||||
|
# Keep the host's own environment out of the assertions below.
|
||||||
|
patcher = mock.patch("url_guard.urllib.request.getproxies", return_value={})
|
||||||
|
self.getproxies = patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def test_install_replaces_and_is_idempotent(self):
|
||||||
|
install_socket_guard()
|
||||||
|
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
||||||
|
# Re-installing must not wrap the wrapper (real fn captured at import).
|
||||||
|
install_socket_guard()
|
||||||
|
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
||||||
|
|
||||||
|
def test_no_proxy_means_no_loopback_allowance(self):
|
||||||
|
install_socket_guard()
|
||||||
|
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||||
|
|
||||||
|
def test_explicit_proxy_is_registered(self):
|
||||||
|
install_socket_guard(proxy_urls=("socks5://127.0.0.1:9050",))
|
||||||
|
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 9050)})
|
||||||
|
|
||||||
|
def test_unset_proxy_option_is_ignored(self):
|
||||||
|
# ytdl_opts.get('proxy') is None when the operator configured no proxy.
|
||||||
|
install_socket_guard(proxy_urls=(None,))
|
||||||
|
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||||
|
|
||||||
|
def test_environment_proxies_are_registered(self):
|
||||||
|
self.getproxies.return_value = {"http": "http://127.0.0.1:8080"}
|
||||||
|
install_socket_guard()
|
||||||
|
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 8080)})
|
||||||
|
|
||||||
|
def test_service_url_is_registered(self):
|
||||||
|
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
|
||||||
|
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 4416)})
|
||||||
|
|
||||||
|
def test_service_and_proxy_endpoints_coexist(self):
|
||||||
|
install_socket_guard(
|
||||||
|
proxy_urls=("socks5://10.1.20.30:1080",),
|
||||||
|
service_urls=("http://127.0.0.1:4416",),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
url_guard._allowed_endpoints,
|
||||||
|
{("10.1.20.30", 1080), ("127.0.0.1", 4416)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_service_urls_reset_between_installs(self):
|
||||||
|
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
|
||||||
|
install_socket_guard()
|
||||||
|
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||||
|
|
||||||
|
def test_endpoints_reset_between_installs(self):
|
||||||
|
install_socket_guard(proxy_urls=("http://127.0.0.1:8080",))
|
||||||
|
install_socket_guard(proxy_urls=(None,))
|
||||||
|
self.assertEqual(url_guard._allowed_endpoints, set())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,17 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
fake_yt_dlp = types.ModuleType("yt_dlp")
|
fake_yt_dlp = types.ModuleType("yt_dlp")
|
||||||
fake_networking = types.ModuleType("yt_dlp.networking")
|
fake_networking = types.ModuleType("yt_dlp.networking")
|
||||||
fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate")
|
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")
|
fake_utils = types.ModuleType("yt_dlp.utils")
|
||||||
|
|
||||||
|
|
||||||
@@ -22,32 +27,202 @@ class _ImpersonateTarget:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class _PostProcessor:
|
||||||
|
def __init__(self, downloader=None):
|
||||||
|
self._downloader = downloader
|
||||||
|
|
||||||
|
|
||||||
|
class _YoutubeDL:
|
||||||
|
"""Minimal stand-in so ``_ConfinedYoutubeDL`` can subclass it under the shim.
|
||||||
|
|
||||||
|
``prepare_filename`` is patched per-test; the real containment logic lives in
|
||||||
|
the ``_ConfinedYoutubeDL`` override, which is what the tests exercise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, params=None, **kwargs):
|
||||||
|
self.params = params or {}
|
||||||
|
|
||||||
|
def prepare_filename(self, *args, **kwargs):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def add_post_processor(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
fake_utils.DownloadError = type("DownloadError", (Exception,), {})
|
||||||
|
fake_utils.YoutubeDLError = fake_utils.DownloadError
|
||||||
|
fake_yt_dlp.YoutubeDL = _YoutubeDL
|
||||||
fake_impersonate.ImpersonateTarget = _ImpersonateTarget
|
fake_impersonate.ImpersonateTarget = _ImpersonateTarget
|
||||||
fake_networking.impersonate = fake_impersonate
|
fake_networking.impersonate = fake_impersonate
|
||||||
|
fake_postprocessor_common.PostProcessor = _PostProcessor
|
||||||
# The inner ``key`` group mirrors the real ``STR_FORMAT_RE_TMPL`` so that
|
# 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
|
# ``_OUTTMPL_FIELD_RE`` (compiled at import time) has the named group that
|
||||||
# ``_resolve_outtmpl_fields`` reads via ``match.group('key')``.
|
# ``_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_RE_TMPL = r"(?P<prefix>)%\((?P<has_key>(?P<key>{}))\)(?P<format>[-0-9.]*{})"
|
||||||
fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa"
|
fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa"
|
||||||
fake_yt_dlp.networking = fake_networking
|
fake_yt_dlp.networking = fake_networking
|
||||||
|
fake_yt_dlp.postprocessor = fake_postprocessor
|
||||||
fake_yt_dlp.utils = fake_utils
|
fake_yt_dlp.utils = fake_utils
|
||||||
sys.modules.setdefault("yt_dlp", fake_yt_dlp)
|
sys.modules.setdefault("yt_dlp", fake_yt_dlp)
|
||||||
sys.modules.setdefault("yt_dlp.networking", fake_networking)
|
sys.modules.setdefault("yt_dlp.networking", fake_networking)
|
||||||
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
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)
|
sys.modules.setdefault("yt_dlp.utils", fake_utils)
|
||||||
|
|
||||||
|
import ytdl
|
||||||
from ytdl import (
|
from ytdl import (
|
||||||
|
Download,
|
||||||
DownloadInfo,
|
DownloadInfo,
|
||||||
|
MusicMetadataPreProcessor,
|
||||||
_compact_persisted_entry,
|
_compact_persisted_entry,
|
||||||
_convert_srt_to_txt_file,
|
_convert_srt_to_txt_file,
|
||||||
|
_pot_provider_urls,
|
||||||
|
_AlbumArtistPostProcessor,
|
||||||
_resolve_outtmpl_fields,
|
_resolve_outtmpl_fields,
|
||||||
_sanitize_entry_for_pickle,
|
_sanitize_entry_for_pickle,
|
||||||
_sanitize_path_component,
|
_sanitize_path_component,
|
||||||
|
_short_title_for_failed_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Detect whether the real yt-dlp is loaded (as opposed to the minimal fake
|
# Detect whether the real yt-dlp is loaded (as opposed to the minimal fake
|
||||||
# shim above). _resolve_outtmpl_fields needs YoutubeDL at runtime.
|
# shim above). _resolve_outtmpl_fields needs YoutubeDL.evaluate_outtmpl at
|
||||||
_has_real_ytdlp = hasattr(sys.modules.get("yt_dlp"), "YoutubeDL")
|
# runtime, which the shim's YoutubeDL stand-in deliberately does not provide.
|
||||||
|
_has_real_ytdlp = hasattr(
|
||||||
|
getattr(sys.modules.get("yt_dlp"), "YoutubeDL", None), "evaluate_outtmpl"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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._ConfinedYoutubeDL', return_value=fake_ydl):
|
||||||
|
result = download._make_youtube_dl({'quiet': True})
|
||||||
|
|
||||||
|
self.assertIs(result, fake_ydl)
|
||||||
|
album_artist_call = fake_ydl.add_post_processor.call_args_list[0]
|
||||||
|
postprocessor, = album_artist_call.args
|
||||||
|
self.assertIsInstance(postprocessor, _AlbumArtistPostProcessor)
|
||||||
|
self.assertEqual(album_artist_call.kwargs, {'when': 'pre_process'})
|
||||||
|
metadata_pre_call = fake_ydl.add_post_processor.call_args_list[1]
|
||||||
|
metadata_preprocessor, = metadata_pre_call.args
|
||||||
|
self.assertIsInstance(metadata_preprocessor, MusicMetadataPreProcessor)
|
||||||
|
self.assertEqual(metadata_pre_call.kwargs, {'when': 'pre_process'})
|
||||||
|
self.assertEqual(fake_ydl.add_post_processor.call_count, 2)
|
||||||
|
|
||||||
|
def test_video_download_does_not_register_postprocessor(self):
|
||||||
|
download = _make_test_download()
|
||||||
|
fake_ydl = MagicMock()
|
||||||
|
|
||||||
|
with patch('ytdl._ConfinedYoutubeDL', return_value=fake_ydl):
|
||||||
|
download._make_youtube_dl({'quiet': True})
|
||||||
|
|
||||||
|
fake_ydl.add_post_processor.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class SanitizePathComponentTests(unittest.TestCase):
|
class SanitizePathComponentTests(unittest.TestCase):
|
||||||
@@ -58,6 +233,24 @@ class SanitizePathComponentTests(unittest.TestCase):
|
|||||||
self.assertIs(_sanitize_path_component(None), None)
|
self.assertIs(_sanitize_path_component(None), None)
|
||||||
self.assertEqual(_sanitize_path_component(42), 42)
|
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")
|
@unittest.skipUnless(_has_real_ytdlp, "requires real yt-dlp")
|
||||||
class ResolveOuttmplFieldsTests(unittest.TestCase):
|
class ResolveOuttmplFieldsTests(unittest.TestCase):
|
||||||
@@ -122,6 +315,112 @@ class ResolveOuttmplFieldsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result, "5 - %(title)s.%(ext)s")
|
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 ConfinedYoutubeDLTests(unittest.TestCase):
|
||||||
|
"""The chokepoint: ``_ConfinedYoutubeDL.prepare_filename`` validates the
|
||||||
|
*resolved* output path (after yt-dlp expands the template) and refuses any
|
||||||
|
write outside the allowed roots. This is the single guard for the download-
|
||||||
|
directory invariant across the main file, split-chapter files, thumbnails,
|
||||||
|
subtitles, etc. — the ``..`` only exists post-expansion, so it is caught here
|
||||||
|
rather than by any ingress string check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.base = os.path.realpath(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def _prepared_path(self, resolved, roots=None):
|
||||||
|
ydl = ytdl._ConfinedYoutubeDL.__new__(ytdl._ConfinedYoutubeDL)
|
||||||
|
ydl._allowed_roots = [self.base] if roots is None else roots
|
||||||
|
with patch.object(
|
||||||
|
ytdl.yt_dlp.YoutubeDL, "prepare_filename", return_value=resolved
|
||||||
|
):
|
||||||
|
return ydl.prepare_filename({})
|
||||||
|
|
||||||
|
def test_chapter_traversal_via_metadata_is_blocked(self):
|
||||||
|
# e.g. chapter_template '%(section_title)s/%(section_title)s/pwned.%(ext)s'
|
||||||
|
# with a chapter titled '..' expands to '../../pwned.mp4'.
|
||||||
|
escaping = os.path.join(self.base, "..", "..", "pwned.mp4")
|
||||||
|
with self.assertRaises(ytdl.yt_dlp.utils.DownloadError):
|
||||||
|
self._prepared_path(escaping)
|
||||||
|
|
||||||
|
def test_absolute_output_path_is_blocked(self):
|
||||||
|
with self.assertRaises(ytdl.yt_dlp.utils.DownloadError):
|
||||||
|
self._prepared_path("/etc/cron.d/evil")
|
||||||
|
|
||||||
|
def test_path_inside_download_dir_is_allowed(self):
|
||||||
|
ok = os.path.join(self.base, "Playlist", "video.mp4")
|
||||||
|
self.assertEqual(self._prepared_path(ok), ok)
|
||||||
|
|
||||||
|
def test_sibling_prefix_directory_is_blocked(self):
|
||||||
|
# base '/x/downloads' must not be escapable to '/x/downloads-secret'.
|
||||||
|
sibling = self.base + "-secret"
|
||||||
|
with self.assertRaises(ytdl.yt_dlp.utils.DownloadError):
|
||||||
|
self._prepared_path(os.path.join(sibling, "video.mp4"))
|
||||||
|
|
||||||
|
def test_empty_and_stdout_targets_pass_through(self):
|
||||||
|
self.assertEqual(self._prepared_path(""), "")
|
||||||
|
self.assertEqual(self._prepared_path("-"), "-")
|
||||||
|
|
||||||
|
def test_overlong_name_is_trimmed_to_fit_the_filesystem(self):
|
||||||
|
# A title long enough to blow the filename limit is what made these
|
||||||
|
# downloads fail outright with [Errno 36] File name too long.
|
||||||
|
long_path = os.path.join(self.base, "a" * 400 + ".mp4")
|
||||||
|
|
||||||
|
result = self._prepared_path(long_path)
|
||||||
|
|
||||||
|
name = os.path.basename(result)
|
||||||
|
self.assertTrue(name.endswith(".mp4"))
|
||||||
|
self.assertLessEqual(len(name.encode("utf-8")), 255 - 32)
|
||||||
|
self.assertEqual(os.path.dirname(result), self.base)
|
||||||
|
# The file must still be writable once yt-dlp adds its own suffixes.
|
||||||
|
self.assertLessEqual(len(f"{name}.f1229065279304024v.part".encode("utf-8")), 255)
|
||||||
|
|
||||||
|
def test_name_within_the_limit_is_left_alone(self):
|
||||||
|
ok = os.path.join(self.base, "Ordinary Title.mp4")
|
||||||
|
self.assertEqual(self._prepared_path(ok), ok)
|
||||||
|
|
||||||
|
def test_limit_counts_bytes_not_characters(self):
|
||||||
|
# 200 CJK characters are 600 bytes: a character count would pass this.
|
||||||
|
long_path = os.path.join(self.base, "音" * 200 + ".mp4")
|
||||||
|
|
||||||
|
name = os.path.basename(self._prepared_path(long_path))
|
||||||
|
|
||||||
|
self.assertLessEqual(len(name.encode("utf-8")), 255 - 32)
|
||||||
|
# A trim landing mid-character must not leave a broken byte sequence.
|
||||||
|
self.assertEqual(name, name.encode("utf-8").decode("utf-8"))
|
||||||
|
self.assertTrue(name.endswith(".mp4"))
|
||||||
|
|
||||||
|
def test_a_long_tail_is_not_mistaken_for_an_extension(self):
|
||||||
|
# os.path.splitext on a title containing a dot late in the string would
|
||||||
|
# otherwise "preserve" a 100-character extension and trim nothing.
|
||||||
|
long_path = os.path.join(self.base, "b" * 300 + "." + "c" * 100)
|
||||||
|
|
||||||
|
name = os.path.basename(self._prepared_path(long_path))
|
||||||
|
|
||||||
|
self.assertLessEqual(len(name.encode("utf-8")), 255 - 32)
|
||||||
|
|
||||||
|
def test_trimming_still_cannot_escape_the_download_directory(self):
|
||||||
|
escaping = os.path.join(self.base, "..", "..", "d" * 400 + ".mp4")
|
||||||
|
with self.assertRaises(ytdl.yt_dlp.utils.DownloadError):
|
||||||
|
self._prepared_path(escaping)
|
||||||
|
|
||||||
|
|
||||||
class SanitizeEntryForPickleTests(unittest.TestCase):
|
class SanitizeEntryForPickleTests(unittest.TestCase):
|
||||||
def test_nested(self):
|
def test_nested(self):
|
||||||
@@ -160,6 +459,444 @@ class SanitizeEntryForPickleTests(unittest.TestCase):
|
|||||||
self.assertEqual(out, {"z": 1, "a": 2})
|
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 DownloadLoggerTests(unittest.TestCase):
|
||||||
|
def test_routes_messages_and_retains_only_non_empty_warnings(self):
|
||||||
|
logger = ytdl._DownloadYtdlLogger()
|
||||||
|
|
||||||
|
with self.assertLogs('ytdl', level='DEBUG') as logs:
|
||||||
|
logger.debug('debug detail')
|
||||||
|
logger.warning(' useful warning ')
|
||||||
|
logger.warning(' ')
|
||||||
|
logger.error('error detail')
|
||||||
|
|
||||||
|
self.assertEqual(logger.warnings, ['useful warning'])
|
||||||
|
self.assertIn('DEBUG:ytdl:debug detail', logs.output)
|
||||||
|
self.assertIn('WARNING:ytdl: useful warning ', logs.output)
|
||||||
|
self.assertIn('ERROR:ytdl:error detail', logs.output)
|
||||||
|
|
||||||
|
def test_retains_only_the_last_distinct_warnings(self):
|
||||||
|
logger = ytdl._DownloadYtdlLogger()
|
||||||
|
cap = ytdl._MAX_RETAINED_WARNINGS
|
||||||
|
|
||||||
|
with self.assertLogs('ytdl', level='WARNING') as logs:
|
||||||
|
for index in range(cap + 3):
|
||||||
|
logger.warning(f'fragment {index} not found')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
logger.warnings,
|
||||||
|
[f'fragment {index} not found' for index in range(3, cap + 3)],
|
||||||
|
)
|
||||||
|
# Every warning still reaches the log; only the retained list is bounded.
|
||||||
|
self.assertEqual(len(logs.output), cap + 3)
|
||||||
|
|
||||||
|
def test_repeated_warning_is_retained_once(self):
|
||||||
|
logger = ytdl._DownloadYtdlLogger()
|
||||||
|
|
||||||
|
with self.assertLogs('ytdl', level='WARNING'):
|
||||||
|
logger.warning('Requested format is not available')
|
||||||
|
logger.warning('Only images are available for download')
|
||||||
|
logger.warning('Requested format is not available')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
logger.warnings,
|
||||||
|
['Requested format is not available', 'Only images are available for download'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failure_message_puts_the_error_last(self):
|
||||||
|
logger = ytdl._DownloadYtdlLogger()
|
||||||
|
|
||||||
|
with self.assertLogs('ytdl', level='WARNING'):
|
||||||
|
logger.warning('Only images are available for download')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
logger.failure_message('ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!'),
|
||||||
|
'Only images are available for download\n'
|
||||||
|
'ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failure_message_skips_a_last_warning_that_repeats_the_error(self):
|
||||||
|
logger = ytdl._DownloadYtdlLogger()
|
||||||
|
|
||||||
|
with self.assertLogs('ytdl', level='WARNING'):
|
||||||
|
logger.warning('Video unavailable')
|
||||||
|
# yt-dlp labels errors but hands warnings to the logger unlabelled,
|
||||||
|
# so the same text can arrive through both routes.
|
||||||
|
logger.warning('Requested format is not available')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
logger.failure_message('ERROR: Requested format is not available'),
|
||||||
|
'Video unavailable\nERROR: Requested format is not available',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failure_message_without_warnings_is_the_error_alone(self):
|
||||||
|
logger = ytdl._DownloadYtdlLogger()
|
||||||
|
|
||||||
|
self.assertEqual(logger.failure_message('ERROR: boom'), 'ERROR: boom')
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadResultTests(unittest.TestCase):
|
||||||
|
def _run_download(self, result=0, warnings=(), error=None):
|
||||||
|
download = _make_test_download()
|
||||||
|
statuses = []
|
||||||
|
download.status_queue = types.SimpleNamespace(put=statuses.append)
|
||||||
|
captured_params = {}
|
||||||
|
|
||||||
|
class FakeYoutubeDL:
|
||||||
|
def download(self, urls):
|
||||||
|
self.urls = urls
|
||||||
|
for warning in warnings:
|
||||||
|
captured_params['logger'].warning(warning)
|
||||||
|
if error is not None:
|
||||||
|
raise error
|
||||||
|
return result
|
||||||
|
|
||||||
|
def make_youtube_dl(params):
|
||||||
|
captured_params.update(params)
|
||||||
|
return FakeYoutubeDL()
|
||||||
|
|
||||||
|
with patch.object(download, '_make_youtube_dl', side_effect=make_youtube_dl), \
|
||||||
|
patch('ytdl.install_socket_guard'), \
|
||||||
|
patch('ytdl.os.setpgrp'):
|
||||||
|
download._download()
|
||||||
|
|
||||||
|
return statuses, captured_params
|
||||||
|
|
||||||
|
def test_nonzero_result_includes_warning_context_and_forwards_logs(self):
|
||||||
|
warnings = [
|
||||||
|
'The uploader has blocked this video in your country',
|
||||||
|
'No video formats found',
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertLogs('ytdl', level='WARNING') as logs:
|
||||||
|
statuses, params = self._run_download(result=1, warnings=warnings)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
statuses[-1],
|
||||||
|
{'status': 'error', 'msg': '\n'.join(warnings)},
|
||||||
|
)
|
||||||
|
self.assertIs(params['logger'].__class__, ytdl._DownloadYtdlLogger)
|
||||||
|
for warning in warnings:
|
||||||
|
self.assertTrue(any(warning in entry for entry in logs.output))
|
||||||
|
|
||||||
|
def test_nonzero_result_without_warning_uses_fallback_message(self):
|
||||||
|
statuses, _ = self._run_download(result=2)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
statuses[-1],
|
||||||
|
{'status': 'error', 'msg': 'yt-dlp failed with exit code 2'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_warning_does_not_change_success_status(self):
|
||||||
|
statuses, _ = self._run_download(result=0, warnings=['A recoverable warning'])
|
||||||
|
|
||||||
|
self.assertEqual(statuses[-1], {'status': 'finished'})
|
||||||
|
|
||||||
|
def test_youtube_dl_error_carries_the_warnings_that_explain_it(self):
|
||||||
|
# The sequence from issue #1047: yt-dlp raises DownloadError, so the
|
||||||
|
# warnings naming the real cause only reach the user if the exception
|
||||||
|
# branch carries them too.
|
||||||
|
statuses, _ = self._run_download(
|
||||||
|
warnings=[
|
||||||
|
'[youtube] Video unavailable. This video contains content from bryhuangpub,'
|
||||||
|
' who has blocked it from display on this website or application',
|
||||||
|
'Only images are available for download. use --list-formats to see them',
|
||||||
|
'Requested format is not available',
|
||||||
|
],
|
||||||
|
error=ytdl.yt_dlp.utils.YoutubeDLError(
|
||||||
|
'ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
statuses[-1],
|
||||||
|
{
|
||||||
|
'status': 'error',
|
||||||
|
'msg': '[youtube] Video unavailable. This video contains content from bryhuangpub,'
|
||||||
|
' who has blocked it from display on this website or application\n'
|
||||||
|
'Only images are available for download. use --list-formats to see them\n'
|
||||||
|
'Requested format is not available\n'
|
||||||
|
'ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_youtube_dl_error_drops_a_last_warning_that_repeats_it(self):
|
||||||
|
statuses, _ = self._run_download(
|
||||||
|
warnings=['Earlier warning', 'Requested format is not available'],
|
||||||
|
error=ytdl.yt_dlp.utils.YoutubeDLError('ERROR: Requested format is not available'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
statuses[-1],
|
||||||
|
{
|
||||||
|
'status': 'error',
|
||||||
|
'msg': 'Earlier warning\nERROR: Requested format is not available',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_youtube_dl_error_message_is_bounded(self):
|
||||||
|
cap = ytdl._MAX_RETAINED_WARNINGS
|
||||||
|
statuses, _ = self._run_download(
|
||||||
|
warnings=[f'fragment {index} not found' for index in range(cap + 4)],
|
||||||
|
error=ytdl.yt_dlp.utils.YoutubeDLError('ERROR: giving up'),
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = statuses[-1]['msg']
|
||||||
|
self.assertEqual(
|
||||||
|
msg.split('\n'),
|
||||||
|
[f'fragment {index} not found' for index in range(4, cap + 4)] + ['ERROR: giving up'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nonzero_result_message_is_bounded(self):
|
||||||
|
cap = ytdl._MAX_RETAINED_WARNINGS
|
||||||
|
statuses, _ = self._run_download(
|
||||||
|
result=1,
|
||||||
|
warnings=[f'fragment {index} not found' for index in range(cap + 4)],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
statuses[-1]['msg'].split('\n'),
|
||||||
|
[f'fragment {index} not found' for index in range(4, cap + 4)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_ytdl_params(download: Download) -> dict:
|
||||||
|
"""Run ``_download`` far enough to capture the params it builds."""
|
||||||
|
fake_ydl = MagicMock()
|
||||||
|
fake_ydl.download.return_value = 0
|
||||||
|
download.status_queue = types.SimpleNamespace(put=lambda _: None)
|
||||||
|
|
||||||
|
with patch('ytdl.install_socket_guard'), \
|
||||||
|
patch.object(Download, '_make_youtube_dl', return_value=fake_ydl) as make:
|
||||||
|
download._download()
|
||||||
|
|
||||||
|
params, = make.call_args.args
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorBlockPostprocessorTests(unittest.TestCase):
|
||||||
|
def test_no_sponsorblock_postprocessors_when_disabled(self):
|
||||||
|
download = _make_test_download()
|
||||||
|
|
||||||
|
params = _capture_ytdl_params(download)
|
||||||
|
|
||||||
|
keys = [pp['key'] for pp in params.get('postprocessors', [])]
|
||||||
|
self.assertNotIn('SponsorBlock', keys)
|
||||||
|
self.assertNotIn('ModifyChapters', keys)
|
||||||
|
|
||||||
|
def test_sponsorblock_pair_matches_the_cli(self):
|
||||||
|
download = _make_test_download()
|
||||||
|
download.info.sponsorblock = True
|
||||||
|
|
||||||
|
params = _capture_ytdl_params(download)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
params['postprocessors'],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'key': 'SponsorBlock',
|
||||||
|
'categories': ['sponsor'],
|
||||||
|
'when': 'after_filter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'ModifyChapters',
|
||||||
|
'remove_sponsor_segments': ['sponsor'],
|
||||||
|
'force_keyframes': False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_segment_removal_runs_before_the_chapter_split(self):
|
||||||
|
# yt-dlp runs same-stage postprocessors in list order, so ModifyChapters
|
||||||
|
# has to rewrite the chapter list before FFmpegSplitChapters cuts the
|
||||||
|
# file up -- the order the CLI builds for
|
||||||
|
# --sponsorblock-remove sponsor --split-chapters.
|
||||||
|
download = _make_test_download()
|
||||||
|
download.info.sponsorblock = True
|
||||||
|
download.info.split_by_chapters = True
|
||||||
|
download.info.chapter_template = '%(section_number)s.%(ext)s'
|
||||||
|
|
||||||
|
params = _capture_ytdl_params(download)
|
||||||
|
|
||||||
|
keys = [pp['key'] for pp in params['postprocessors']]
|
||||||
|
self.assertEqual(keys, ['SponsorBlock', 'ModifyChapters', 'FFmpegSplitChapters'])
|
||||||
|
self.assertEqual(params['outtmpl']['chapter'], '%(section_number)s.%(ext)s')
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class ConvertSrtToTxtTests(unittest.TestCase):
|
||||||
def test_basic_conversion(self):
|
def test_basic_conversion(self):
|
||||||
srt = """1
|
srt = """1
|
||||||
@@ -180,6 +917,77 @@ Second line
|
|||||||
self.assertIn("Hello world", content)
|
self.assertIn("Hello world", content)
|
||||||
self.assertIn("Second line", 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):
|
class DownloadInfoSetstateTests(unittest.TestCase):
|
||||||
def _base_state(self, **kwargs):
|
def _base_state(self, **kwargs):
|
||||||
@@ -309,5 +1117,62 @@ class CompactPersistedEntryTests(unittest.TestCase):
|
|||||||
self.assertIsNone(_compact_persisted_entry({"id": "x", "title": "y"}))
|
self.assertIsNone(_compact_persisted_entry({"id": "x", "title": "y"}))
|
||||||
|
|
||||||
|
|
||||||
|
class ShortTitleForFailedUrlTests(unittest.TestCase):
|
||||||
|
def test_uses_hostname_for_a_normal_url(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_short_title_for_failed_url("https://example.com/watch?v=1"),
|
||||||
|
"example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_falls_back_to_raw_value_when_there_is_no_hostname(self):
|
||||||
|
# file:// URIs and bare search terms/video IDs have no netloc to extract.
|
||||||
|
self.assertEqual(_short_title_for_failed_url("file:///etc/passwd"), "file:///etc/passwd")
|
||||||
|
self.assertEqual(_short_title_for_failed_url("ytsearch:some query"), "ytsearch:some query")
|
||||||
|
|
||||||
|
def test_falls_back_to_raw_value_on_unparseable_input(self):
|
||||||
|
# A malformed IPv6-looking host raises ValueError in urlsplit().hostname.
|
||||||
|
malformed = "https://[::1/watch"
|
||||||
|
self.assertEqual(_short_title_for_failed_url(malformed), malformed)
|
||||||
|
|
||||||
|
|
||||||
|
class PotProviderUrlsTests(unittest.TestCase):
|
||||||
|
"""#1064: the connect-time guard must let the download reach the PO token
|
||||||
|
provider, so it has to know every endpoint yt-dlp might dial for one."""
|
||||||
|
|
||||||
|
def test_bundled_provider_by_default(self):
|
||||||
|
self.assertEqual(_pot_provider_urls({}), ("http://127.0.0.1:4416",))
|
||||||
|
|
||||||
|
def test_configured_base_url_is_added(self):
|
||||||
|
urls = _pot_provider_urls({
|
||||||
|
"extractor_args": {"youtubepot-bgutilhttp": {"base_url": ["http://pot:4416"]}},
|
||||||
|
})
|
||||||
|
# The bundled server runs regardless, so both stay reachable.
|
||||||
|
self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416"))
|
||||||
|
|
||||||
|
def test_deprecated_base_url_arg_is_honoured(self):
|
||||||
|
urls = _pot_provider_urls({
|
||||||
|
"extractor_args": {"youtube": {"getpot_bgutil_baseurl": ["http://pot:4416"]}},
|
||||||
|
})
|
||||||
|
self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416"))
|
||||||
|
|
||||||
|
def test_unrelated_extractor_args_are_ignored(self):
|
||||||
|
urls = _pot_provider_urls({
|
||||||
|
"extractor_args": {"youtube": {"player_client": ["web"]}},
|
||||||
|
})
|
||||||
|
self.assertEqual(urls, ("http://127.0.0.1:4416",))
|
||||||
|
|
||||||
|
def test_malformed_extractor_args_do_not_raise(self):
|
||||||
|
# YTDL_OPTIONS is operator-supplied JSON and reaches here unvalidated.
|
||||||
|
for opts in (
|
||||||
|
{"extractor_args": None},
|
||||||
|
{"extractor_args": "youtube:player_client=web"},
|
||||||
|
{"extractor_args": {"youtubepot-bgutilhttp": "http://pot:4416"}},
|
||||||
|
{"extractor_args": {"youtubepot-bgutilhttp": {"base_url": []}}},
|
||||||
|
):
|
||||||
|
with self.subTest(opts=opts):
|
||||||
|
self.assertEqual(_pot_provider_urls(opts), ("http://127.0.0.1:4416",))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Lightweight SSRF guard for user-submitted URLs.
|
||||||
|
|
||||||
|
MeTube hands user-submitted URLs to yt-dlp, whose generic extractor will fetch
|
||||||
|
any ``http(s)`` URL. Without a guard, an attacker can make the server fetch
|
||||||
|
internal endpoints (cloud metadata services, loopback, RFC1918 hosts, etc.) and
|
||||||
|
have the response saved to the download directory and served back.
|
||||||
|
|
||||||
|
This module provides two layers:
|
||||||
|
|
||||||
|
* ``validate_url`` — a cheap validator applied at every URL ingress.
|
||||||
|
* ``install_socket_guard`` — a connect-time ``getaddrinfo`` guard installed in
|
||||||
|
the download subprocess, which re-validates every resolved address and so
|
||||||
|
covers redirects, DNS rebinding, and media URLs yt-dlp derives from remote
|
||||||
|
metadata — for any backend that resolves through Python's socket module.
|
||||||
|
|
||||||
|
Known limitations — network isolation (e.g. Docker) remains the backstop for
|
||||||
|
all of these:
|
||||||
|
|
||||||
|
* The socket guard is installed only in the download subprocess. Metadata
|
||||||
|
extraction (``ytdl.DownloadQueue.__extract_info``) runs in the main process,
|
||||||
|
where installing a process-wide guard would reject the server's own bind on
|
||||||
|
``HOST=0.0.0.0``. So extraction — which also follows redirects — is covered
|
||||||
|
only by ``validate_url`` at ingress, not at connect time; a redirect from an
|
||||||
|
allowed host to an internal one during extraction is not blocked (a lower-
|
||||||
|
impact, blind SSRF, since the extraction response is not written to disk).
|
||||||
|
* Native resolvers (curl_cffi/libcurl via ``--impersonate``) resolve outside
|
||||||
|
Python's socket module and bypass the connect-time guard entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
log = logging.getLogger('url_guard')
|
||||||
|
|
||||||
|
_ALLOWED_SCHEMES = ('http', 'https')
|
||||||
|
|
||||||
|
# Ports to assume when a configured endpoint URL omits one, per scheme.
|
||||||
|
_SCHEME_DEFAULT_PORTS = {
|
||||||
|
'http': 80,
|
||||||
|
'https': 443,
|
||||||
|
'socks4': 1080,
|
||||||
|
'socks4a': 1080,
|
||||||
|
'socks5': 1080,
|
||||||
|
'socks5h': 1080,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hostnames that must be blocked without needing a lookup. ``localhost`` and any
|
||||||
|
# subdomain of it are conventionally loopback, and the GCP metadata name is a
|
||||||
|
# well-known SSRF target that may resolve via a resolver we don't control.
|
||||||
|
_BLOCKED_HOSTNAMES = ('localhost', 'metadata.google.internal')
|
||||||
|
|
||||||
|
|
||||||
|
def _hostname_is_blocked(hostname: str) -> bool:
|
||||||
|
host = hostname.rstrip('.').lower()
|
||||||
|
for blocked in _BLOCKED_HOSTNAMES:
|
||||||
|
if host == blocked or host.endswith('.' + blocked):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# IPv6 ranges that tunnel an IPv4 address at a fixed offset. ``is_global``
|
||||||
|
# judges only the outer address, so an internal IPv4 wrapped in one of these can
|
||||||
|
# pass a check the bare address would fail — 64:ff9b::a9fe:a9fe carries the cloud
|
||||||
|
# metadata address but sits in the 2000::/3 global unicast range.
|
||||||
|
_NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network('64:ff9b::/96')
|
||||||
|
_IPV4_COMPATIBLE = ipaddress.ip_network('::/96')
|
||||||
|
|
||||||
|
# ``::`` and ``::1`` sit inside ::/96 without being IPv4-compatible addresses
|
||||||
|
# (RFC 4291 reserves both), and 0.0.0.0/8 is not a routable destination anyway.
|
||||||
|
# Reading a tunnelled address out of them would just misdescribe them.
|
||||||
|
_UNUSABLE_IPV4 = ipaddress.ip_network('0.0.0.0/8')
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_ip(addr: str):
|
||||||
|
"""Parse *addr*, unwrapping IPv4-mapped IPv6 (e.g. ``::ffff:169.254.169.254``)
|
||||||
|
so the embedded IPv4 address is judged on its own merits. Returns ``None``
|
||||||
|
when *addr* is not a valid IP literal."""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(addr)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||||
|
ip = ip.ipv4_mapped
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def _tunnelled_ipv4(ip):
|
||||||
|
"""The IPv4 address an IPv6 transition form tunnels, or ``None``.
|
||||||
|
|
||||||
|
Covers 6to4 (``2002::/16``), Teredo (``2001::/32``), the NAT64 well-known
|
||||||
|
prefix (``64:ff9b::/96``) and the deprecated IPv4-compatible form
|
||||||
|
(``::/96``). IPv4-mapped is handled by ``_normalise_ip`` instead: that form
|
||||||
|
*is* its embedded address rather than a tunnel to it.
|
||||||
|
"""
|
||||||
|
if not isinstance(ip, ipaddress.IPv6Address):
|
||||||
|
return None
|
||||||
|
if ip.sixtofour is not None:
|
||||||
|
return ip.sixtofour
|
||||||
|
if ip.teredo is not None:
|
||||||
|
return ip.teredo[1]
|
||||||
|
if ip in _NAT64_WELL_KNOWN_PREFIX or ip in _IPV4_COMPATIBLE:
|
||||||
|
tunnelled = ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
|
||||||
|
return None if tunnelled in _UNUSABLE_IPV4 else tunnelled
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ips_to_judge(addr: str) -> tuple:
|
||||||
|
"""Every address a verdict on *addr* has to account for: the address itself
|
||||||
|
plus any IPv4 it tunnels. Empty when *addr* is not a valid IP literal.
|
||||||
|
|
||||||
|
A tunnelled address is judged on *both* halves, so unwrapping can only ever
|
||||||
|
tighten the verdict. Returning the embedded address alone would be a way in:
|
||||||
|
Python already rejects all of 2002::/16 and 2001::/32, and replacing
|
||||||
|
``2002:0808:0808::`` with the global 8.8.8.8 would turn an address the guard
|
||||||
|
blocks today into an allowed one.
|
||||||
|
"""
|
||||||
|
ip = _normalise_ip(addr)
|
||||||
|
if ip is None:
|
||||||
|
return ()
|
||||||
|
tunnelled = _tunnelled_ipv4(ip)
|
||||||
|
return (ip,) if tunnelled is None else (ip, tunnelled)
|
||||||
|
|
||||||
|
|
||||||
|
def _address_is_global(addr: str) -> bool:
|
||||||
|
ips = _ips_to_judge(addr)
|
||||||
|
return bool(ips) and all(ip.is_global for ip in ips)
|
||||||
|
|
||||||
|
|
||||||
|
def _address_allowed_at_connect(addr: str, is_allowed_endpoint: bool = False) -> bool:
|
||||||
|
"""True if *addr* may be connected to at download time.
|
||||||
|
|
||||||
|
Permits global addresses, and anything at all when the destination is an
|
||||||
|
endpoint the operator or the image configured — a proxy, or the PO token
|
||||||
|
provider (see ``_is_allowed_endpoint``). Internal addresses are otherwise
|
||||||
|
refused with no blanket exception: media URLs that yt-dlp derives from a
|
||||||
|
remote manifest are attacker-controlled and reach this policy without passing
|
||||||
|
``validate_url``, so any range opened here is a range a hostile playlist can
|
||||||
|
read from the server's own network. Blocks link-local
|
||||||
|
(cloud metadata at 169.254.169.254), private (RFC1918), loopback,
|
||||||
|
unique-local and every other non-global range.
|
||||||
|
"""
|
||||||
|
ips = _ips_to_judge(addr)
|
||||||
|
if not ips:
|
||||||
|
return False
|
||||||
|
return is_allowed_endpoint or all(ip.is_global for ip in ips)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_endpoint(url: str):
|
||||||
|
"""Parse a configured URL into a ``(hostname, port)`` pair, or ``None`` if it
|
||||||
|
has no usable host. Used to scope the internal-address allowance to that
|
||||||
|
endpoint alone."""
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
return None
|
||||||
|
candidate = url.strip()
|
||||||
|
if '://' not in candidate:
|
||||||
|
# Bare host:port, as accepted by the *_proxy environment variables.
|
||||||
|
candidate = '//' + candidate
|
||||||
|
try:
|
||||||
|
parts = urlsplit(candidate)
|
||||||
|
hostname, port = parts.hostname, parts.port
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
if port is None:
|
||||||
|
port = _SCHEME_DEFAULT_PORTS.get(parts.scheme.lower())
|
||||||
|
return (hostname.rstrip('.').lower(), port)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoints(urls) -> set:
|
||||||
|
"""The parseable endpoints among *urls*, dropping any that name no host."""
|
||||||
|
return {ep for ep in map(_url_endpoint, urls) if ep is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_proxy_endpoints(proxy_urls) -> set:
|
||||||
|
"""Endpoints of every proxy this download may legitimately dial: the explicit
|
||||||
|
yt-dlp ``proxy`` option plus the ``*_proxy`` environment variables yt-dlp falls
|
||||||
|
back to. All are operator-configured, unlike the URLs inside fetched media."""
|
||||||
|
candidates = list(proxy_urls) + list(urllib.request.getproxies().values())
|
||||||
|
return _endpoints(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
# Captured at import so re-installing the guard never wraps the wrapper.
|
||||||
|
_real_getaddrinfo = socket.getaddrinfo
|
||||||
|
|
||||||
|
# Populated by install_socket_guard; empty means no internal destination is allowed.
|
||||||
|
_allowed_endpoints: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_port(port):
|
||||||
|
if isinstance(port, str):
|
||||||
|
try:
|
||||||
|
return int(port)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
return socket.getservbyname(port)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_endpoint(host, port) -> bool:
|
||||||
|
"""True when host:port is exactly one of the endpoints this download is
|
||||||
|
configured to dial — a proxy or the PO token provider. Matching is on the
|
||||||
|
configured host *string*, not on the resolved address, so a hostile media URL
|
||||||
|
cannot borrow the allowance by resolving to the same address under a
|
||||||
|
different name."""
|
||||||
|
if not _allowed_endpoints or host is None:
|
||||||
|
return False
|
||||||
|
return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_endpoints
|
||||||
|
|
||||||
|
|
||||||
|
def _guarded_getaddrinfo(host, *args, **kwargs):
|
||||||
|
results = _real_getaddrinfo(host, *args, **kwargs)
|
||||||
|
# Mirrors getaddrinfo(host, port, ...): port is the first optional argument.
|
||||||
|
port = args[0] if args else kwargs.get('port')
|
||||||
|
is_configured = _is_allowed_endpoint(host, port)
|
||||||
|
allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_configured)]
|
||||||
|
if not allowed:
|
||||||
|
raise socket.gaierror(f'Refusing to connect to non-global address for host {host!r}')
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
def install_socket_guard(allow_private: bool = False, proxy_urls=(), service_urls=()) -> None:
|
||||||
|
"""Enforce the no-internal-hosts policy at actual connection time.
|
||||||
|
|
||||||
|
``validate_url`` only checks the *submitted* URL string; yt-dlp then follows
|
||||||
|
HTTP redirects and resolves media URLs from remote metadata without
|
||||||
|
re-validating them. Installing this in the download subprocess re-checks
|
||||||
|
every resolved address at connect time, covering redirects, DNS rebinding and
|
||||||
|
manifest-derived media URLs for any networking backend that resolves through
|
||||||
|
Python's socket module (urllib, requests). Native resolvers — notably
|
||||||
|
curl_cffi/libcurl used by ``--impersonate`` — bypass this and rely on network
|
||||||
|
isolation as the backstop.
|
||||||
|
|
||||||
|
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
|
||||||
|
the ``*_proxy`` environment variables are picked up automatically), and
|
||||||
|
*service_urls* the helper services the download itself has to reach — the PO
|
||||||
|
token provider this image ships and starts on loopback. Each is reachable at
|
||||||
|
its own host:port wherever it lives — loopback, the LAN, a VPN range — and
|
||||||
|
nothing else internal is. That costs those setups nothing and gives away
|
||||||
|
little: yt-dlp dials each at exactly that host:port, and a media URL is either
|
||||||
|
handed to the proxy unresolved or resolved on its own merits — never
|
||||||
|
inheriting the allowance. A hostile media URL naming an allowed endpoint
|
||||||
|
reaches only what is listening there: a proxy that would have fetched it
|
||||||
|
anyway, or a token server with two endpoints and nothing to read.
|
||||||
|
|
||||||
|
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
|
||||||
|
installed at all, so proxy/VPN setups that route through private or Fake-IP
|
||||||
|
ranges keep working.
|
||||||
|
"""
|
||||||
|
if allow_private:
|
||||||
|
return
|
||||||
|
proxy_endpoints = _collect_proxy_endpoints(proxy_urls)
|
||||||
|
service_endpoints = _endpoints(service_urls) - proxy_endpoints
|
||||||
|
_allowed_endpoints.clear()
|
||||||
|
_allowed_endpoints.update(proxy_endpoints | service_endpoints)
|
||||||
|
for label, endpoints in (('proxy', proxy_endpoints), ('service', service_endpoints)):
|
||||||
|
for host, port in sorted(endpoints, key=lambda ep: (ep[0], ep[1] or 0)):
|
||||||
|
log.info(f'Allowing connections to configured {label} {host}:{port}')
|
||||||
|
socket.getaddrinfo = _guarded_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(url: str, allow_private: bool = False) -> str | None:
|
||||||
|
"""Return an error message if the URL is disallowed, else ``None``.
|
||||||
|
|
||||||
|
Inputs without a ``://`` scheme separator (bare video IDs, ``ytsearch:``
|
||||||
|
and other yt-dlp search/extractor prefixes) are allowed unchanged so that
|
||||||
|
non-URL entries keep working.
|
||||||
|
|
||||||
|
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the internal-host
|
||||||
|
and internal-address checks are skipped so that trusted proxy/VPN setups —
|
||||||
|
e.g. Fake-IP clients that resolve YouTube to ``198.18.0.0/15`` — can be used.
|
||||||
|
Scheme validation (http/https only) still applies.
|
||||||
|
"""
|
||||||
|
if not isinstance(url, str):
|
||||||
|
return 'Invalid URL'
|
||||||
|
|
||||||
|
candidate = url.strip()
|
||||||
|
if '://' not in candidate:
|
||||||
|
# Not an absolute URL: bare video IDs, ytsearch: prefixes, etc.
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = urlsplit(candidate)
|
||||||
|
scheme = parts.scheme.lower()
|
||||||
|
if scheme not in _ALLOWED_SCHEMES:
|
||||||
|
return f'URL scheme "{parts.scheme}" is not allowed (only http and https)'
|
||||||
|
|
||||||
|
hostname = parts.hostname
|
||||||
|
if not hostname:
|
||||||
|
return 'URL is missing a host'
|
||||||
|
|
||||||
|
if allow_private:
|
||||||
|
# Environment is explicitly trusted: skip the SSRF address checks.
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _hostname_is_blocked(hostname):
|
||||||
|
return f'Refusing to fetch internal host "{hostname}"'
|
||||||
|
|
||||||
|
try:
|
||||||
|
addrinfo = socket.getaddrinfo(hostname, parts.port, proto=socket.IPPROTO_TCP)
|
||||||
|
except socket.gaierror:
|
||||||
|
# Fail closed: a host we cannot resolve is a host we cannot verify as
|
||||||
|
# non-internal, so refuse it rather than letting the download proceed
|
||||||
|
# to a target that may resolve differently at fetch time.
|
||||||
|
return f'Could not resolve host "{hostname}"'
|
||||||
|
except (UnicodeError, ValueError):
|
||||||
|
return f'Invalid host "{hostname}"'
|
||||||
|
|
||||||
|
for family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||||
|
addr = sockaddr[0]
|
||||||
|
if not _address_is_global(addr):
|
||||||
|
return f'Refusing to fetch internal address "{addr}" for host "{hostname}"'
|
||||||
|
|
||||||
|
return None
|
||||||
+825
-103
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 885 KiB After Width: | Height: | Size: 1.9 MiB |
+21
-20
@@ -21,23 +21,24 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@11.5.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^22.0.4",
|
"@angular/animations": "^22.1.2",
|
||||||
"@angular/common": "^22.0.4",
|
"@angular/common": "^22.1.2",
|
||||||
"@angular/compiler": "^22.0.4",
|
"@angular/compiler": "^22.1.2",
|
||||||
"@angular/core": "^22.0.4",
|
"@angular/core": "^22.1.2",
|
||||||
"@angular/forms": "^22.0.4",
|
"@angular/forms": "^22.1.2",
|
||||||
"@angular/platform-browser": "^22.0.4",
|
"@angular/platform-browser": "^22.1.2",
|
||||||
"@angular/platform-browser-dynamic": "^22.0.4",
|
"@angular/platform-browser-dynamic": "^22.1.2",
|
||||||
"@angular/service-worker": "^22.0.4",
|
"@angular/service-worker": "^22.1.2",
|
||||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.3.0",
|
"@fortawesome/free-brands-svg-icons": "^7.3.1",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.3.0",
|
"@fortawesome/free-regular-svg-icons": "^7.3.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "^23.2.0",
|
"@ng-select/ng-select": "^23.11.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"ngx-cookie-service": "^22.0.0",
|
"ngx-cookie-service": "^22.0.0",
|
||||||
@@ -48,16 +49,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "22.0.0",
|
"@angular-eslint/builder": "22.0.0",
|
||||||
"@angular/build": "^22.0.4",
|
"@angular/build": "^22.1.4",
|
||||||
"@angular/cli": "^22.0.4",
|
"@angular/cli": "^22.1.4",
|
||||||
"@angular/compiler-cli": "^22.0.4",
|
"@angular/compiler-cli": "^22.1.2",
|
||||||
"@angular/localize": "^22.0.4",
|
"@angular/localize": "^22.1.2",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.5",
|
||||||
"angular-eslint": "22.0.0",
|
"angular-eslint": "22.0.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.5",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "8.62.0",
|
"typescript-eslint": "8.62.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2049
-2394
File diff suppressed because it is too large
Load Diff
+42
-4
@@ -399,6 +399,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="row g-2 align-items-center">
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-sponsorblock"
|
||||||
|
name="sponsorblock" [(ngModel)]="sponsorblock" (change)="sponsorblockChanged()"
|
||||||
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
|
<label class="form-check-label" for="checkbox-sponsorblock"
|
||||||
|
ngbPopover="Cut out sponsor segments using SponsorBlock's crowd-sourced markers (YouTube only)."
|
||||||
|
triggers="hover" container="body">Remove sponsor segments</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
||||||
@@ -693,13 +703,14 @@
|
|||||||
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Video</th>
|
<th scope="col">Video</th>
|
||||||
|
<th scope="col" style="width: 7rem;">Format</th>
|
||||||
<th scope="col" style="width: 8rem;">Speed</th>
|
<th scope="col" style="width: 8rem;">Speed</th>
|
||||||
<th scope="col" style="width: 7rem;">ETA</th>
|
<th scope="col" style="width: 7rem;">ETA</th>
|
||||||
<th scope="col" style="width: 6rem;"></th>
|
<th scope="col" style="width: 6rem;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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'>
|
<tr [class.disabled]='download.value.deleting'>
|
||||||
<td>
|
<td>
|
||||||
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
||||||
@@ -726,6 +737,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-nowrap">{{ formatLabel(download.value) }}</td>
|
||||||
<td>{{ download.value.speed | speed }}</td>
|
<td>{{ download.value.speed | speed }}</td>
|
||||||
<td>{{ download.value.eta | eta }}</td>
|
<td>{{ download.value.eta | eta }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -757,7 +769,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 1rem;">
|
<th scope="col" style="width: 1rem;">
|
||||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
|
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Video</th>
|
<th scope="col">Video</th>
|
||||||
<th scope="col">Type</th>
|
<th scope="col">Type</th>
|
||||||
@@ -769,7 +781,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (entry of cachedSortedDone; track entry[1].id) {
|
@for (entry of cachedSortedDone; track entry[0]) {
|
||||||
<tr [class.disabled]='entry[1].deleting'>
|
<tr [class.disabled]='entry[1].deleting'>
|
||||||
<td>
|
<td>
|
||||||
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||||
@@ -958,7 +970,33 @@
|
|||||||
[disabled]="downloads.loading"
|
[disabled]="downloads.loading"
|
||||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||||
</td>
|
</td>
|
||||||
<td>{{ entry[1].name }}</td>
|
<td>
|
||||||
|
@if (editingNameId === entry[0]) {
|
||||||
|
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-sm flex-grow-1"
|
||||||
|
[name]="'subName' + entry[0]"
|
||||||
|
[(ngModel)]="nameEditDraft"
|
||||||
|
[maxlength]="subscriptionNameMaxLength"
|
||||||
|
[disabled]="downloads.loading"
|
||||||
|
[attr.aria-label]="'Subscription name for ' + entry[1].name" />
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
(click)="saveName(entry[0])"
|
||||||
|
[disabled]="downloads.loading">Save</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
(click)="cancelEditName()"
|
||||||
|
[disabled]="downloads.loading">Cancel</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||||
|
<span class="text-break">{{ entry[1].name }}</span>
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0"
|
||||||
|
(click)="beginEditName(entry[0], entry[1].name)"
|
||||||
|
[disabled]="downloads.loading"
|
||||||
|
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||||
<td>
|
<td>
|
||||||
@if (editingTitleRegexId === entry[0]) {
|
@if (editingTitleRegexId === entry[0]) {
|
||||||
|
|||||||
+141
-4
@@ -6,6 +6,7 @@ import { DownloadsService } from './services/downloads.service';
|
|||||||
import { SubscriptionsService } from './services/subscriptions.service';
|
import { SubscriptionsService } from './services/subscriptions.service';
|
||||||
import { ToastService } from './services/toast.service';
|
import { ToastService } from './services/toast.service';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
|
import { Download } from './interfaces';
|
||||||
|
|
||||||
class DownloadsServiceStub {
|
class DownloadsServiceStub {
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -19,6 +20,7 @@ class DownloadsServiceStub {
|
|||||||
customDirsChanged = new Subject<Record<string, string[]>>();
|
customDirsChanged = new Subject<Record<string, string[]>>();
|
||||||
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
||||||
updated = new Subject<void>();
|
updated = new Subject<void>();
|
||||||
|
retryCalls: string[] = [];
|
||||||
|
|
||||||
getCookieStatus() {
|
getCookieStatus() {
|
||||||
return of({ status: 'ok', has_cookies: false });
|
return of({ status: 'ok', has_cookies: false });
|
||||||
@@ -32,6 +34,11 @@ class DownloadsServiceStub {
|
|||||||
return of({ status: 'ok' as const });
|
return of({ status: 'ok' as const });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retry(id: string) {
|
||||||
|
this.retryCalls.push(id);
|
||||||
|
return of({ status: 'ok' as const });
|
||||||
|
}
|
||||||
|
|
||||||
cancelAdd() {
|
cancelAdd() {
|
||||||
return of({ status: 'ok' as const });
|
return of({ status: 'ok' as const });
|
||||||
}
|
}
|
||||||
@@ -75,7 +82,10 @@ class SubscriptionsServiceStub {
|
|||||||
return of({});
|
return of({});
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
updateCalls: [string, unknown][] = [];
|
||||||
|
|
||||||
|
update(id: string, changes: unknown) {
|
||||||
|
this.updateCalls.push([id, changes]);
|
||||||
return of({ status: 'ok' as const });
|
return of({ status: 'ok' as const });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +149,31 @@ describe('App', () => {
|
|||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pre-fills the download folder from DEFAULT_FOLDER', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.folder).toBe('youtube');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite a folder the user already typed', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.folder = 'music';
|
||||||
|
|
||||||
|
downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.folder).toBe('music');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('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', () => {
|
it('hides manual override input when disabled', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
fixture.componentInstance.isAdvancedOpen = true;
|
fixture.componentInstance.isAdvancedOpen = true;
|
||||||
@@ -214,6 +249,46 @@ describe('App', () => {
|
|||||||
expect(root.textContent).toContain('starts in');
|
expect(root.textContent).toContain('starts in');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the queued format in the Downloading table', () => {
|
||||||
|
downloads.queue.set('https://example.com/v', {
|
||||||
|
id: 'v1',
|
||||||
|
title: 'Some Video',
|
||||||
|
url: 'https://example.com/v',
|
||||||
|
download_type: 'audio',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'flac',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'downloading',
|
||||||
|
msg: '',
|
||||||
|
percent: 10,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
downloads.queueChanged.next();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const row = (fixture.nativeElement as HTMLElement).querySelector('tbody tr');
|
||||||
|
expect(row?.textContent).toContain('FLAC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels formats the way the form does, and copes with an unknown one', () => {
|
||||||
|
const app = TestBed.createComponent(App).componentInstance;
|
||||||
|
const base = { format: '' } as Download;
|
||||||
|
|
||||||
|
expect(app.formatLabel({ ...base, format: 'any' })).toBe('Auto');
|
||||||
|
expect(app.formatLabel({ ...base, format: 'mp4' })).toBe('MP4');
|
||||||
|
expect(app.formatLabel({ ...base, format: 'srt' })).toBe('SRT');
|
||||||
|
// A format from a record older than the option list still reads sensibly.
|
||||||
|
expect(app.formatLabel({ ...base, format: 'mkv' })).toBe('MKV');
|
||||||
|
expect(app.formatLabel(base)).toBe('-');
|
||||||
|
});
|
||||||
|
|
||||||
it('includes titleRegex in subscribe payload', () => {
|
it('includes titleRegex in subscribe payload', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
@@ -239,7 +314,9 @@ describe('App', () => {
|
|||||||
expect(payload.skipSubscriberOnly).toBe(true);
|
expect(payload.skipSubscriberOnly).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits clip fields from subscribe payload', () => {
|
it('passes clip fields through to the subscribe payload', () => {
|
||||||
|
// #1049: a subscription's options apply to all its future downloads, and
|
||||||
|
// clip bounds used to be stripped out on the way.
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||||
@@ -249,8 +326,8 @@ describe('App', () => {
|
|||||||
app.addSubscription();
|
app.addSubscription();
|
||||||
expect(subs.subscribeCalls.length).toBe(1);
|
expect(subs.subscribeCalls.length).toBe(1);
|
||||||
const payload = subs.subscribeCalls[0] as Record<string, unknown>;
|
const payload = subs.subscribeCalls[0] as Record<string, unknown>;
|
||||||
expect('clipStart' in payload).toBe(false);
|
expect(payload['clipStart']).toBe('1:00');
|
||||||
expect('clipEnd' in payload).toBe(false);
|
expect(payload['clipEnd']).toBe('2:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildAddPayload includes clip times', () => {
|
it('buildAddPayload includes clip times', () => {
|
||||||
@@ -263,6 +340,33 @@ describe('App', () => {
|
|||||||
expect(payload.clipEnd).toBe('1:20');
|
expect(payload.clipEnd).toBe('1:20');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries a failed download by its server-side queue id', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const download = {
|
||||||
|
id: 'vid1',
|
||||||
|
title: 'Test Video',
|
||||||
|
url: 'https://example.com/v',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'error',
|
||||||
|
msg: 'temporary failure',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.retryDownload(download.url, download);
|
||||||
|
|
||||||
|
expect(downloads.retryCalls).toEqual([download.url]);
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks subscribe with invalid title regex', () => {
|
it('blocks subscribe with invalid title regex', () => {
|
||||||
const toasts = TestBed.inject(ToastService);
|
const toasts = TestBed.inject(ToastService);
|
||||||
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
||||||
@@ -276,4 +380,37 @@ describe('App', () => {
|
|||||||
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||||
errorSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renames a subscription and closes the inline editor', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||||
|
|
||||||
|
app.beginEditName('sub1', 'Videos');
|
||||||
|
expect(app.editingNameId).toBe('sub1');
|
||||||
|
expect(app.nameEditDraft).toBe('Videos');
|
||||||
|
|
||||||
|
app.nameEditDraft = ' Jane uploads ';
|
||||||
|
app.saveName('sub1');
|
||||||
|
|
||||||
|
expect(subs.updateCalls).toEqual([['sub1', { name: 'Jane uploads' }]]);
|
||||||
|
expect(app.editingNameId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks renaming a subscription to an empty name', () => {
|
||||||
|
const toasts = TestBed.inject(ToastService);
|
||||||
|
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||||
|
|
||||||
|
app.beginEditName('sub1', 'Videos');
|
||||||
|
app.nameEditDraft = ' ';
|
||||||
|
app.saveName('sub1');
|
||||||
|
|
||||||
|
expect(subs.updateCalls.length).toBe(0);
|
||||||
|
expect(app.editingNameId).toBe('sub1');
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('Subscription name must not be empty');
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+100
-41
@@ -86,6 +86,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
playlistItemLimit!: number;
|
playlistItemLimit!: number;
|
||||||
splitByChapters: boolean;
|
splitByChapters: boolean;
|
||||||
|
sponsorblock: boolean;
|
||||||
chapterTemplate: string;
|
chapterTemplate: string;
|
||||||
clipStart = '';
|
clipStart = '';
|
||||||
clipEnd = '';
|
clipEnd = '';
|
||||||
@@ -102,6 +103,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
skipSubscriberOnly = false;
|
skipSubscriberOnly = false;
|
||||||
editingTitleRegexId: string | null = null;
|
editingTitleRegexId: string | null = null;
|
||||||
titleRegexEditDraft = '';
|
titleRegexEditDraft = '';
|
||||||
|
editingNameId: string | null = null;
|
||||||
|
nameEditDraft = '';
|
||||||
|
readonly subscriptionNameMaxLength = 200;
|
||||||
cachedSubs: [string, SubscriptionRow][] = [];
|
cachedSubs: [string, SubscriptionRow][] = [];
|
||||||
selectedSubscriptionIds = new Set<string>();
|
selectedSubscriptionIds = new Set<string>();
|
||||||
checkingSubscriptionIds = new Set<string>();
|
checkingSubscriptionIds = new Set<string>();
|
||||||
@@ -134,6 +138,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
sortAscending = false;
|
sortAscending = false;
|
||||||
expandedErrors: Set<string> = new Set<string>();
|
expandedErrors: Set<string> = new Set<string>();
|
||||||
cachedSortedDone: [string, Download][] = [];
|
cachedSortedDone: [string, Download][] = [];
|
||||||
|
// The done ids in rendered order, so a shift-click range follows the sort
|
||||||
|
// the user is looking at rather than the map's insertion order.
|
||||||
|
cachedSortedDoneIds: string[] = [];
|
||||||
lastCopiedErrorId: string | null = null;
|
lastCopiedErrorId: string | null = null;
|
||||||
private previousDownloadType = 'video';
|
private previousDownloadType = 'video';
|
||||||
private addRequestSub?: Subscription;
|
private addRequestSub?: Subscription;
|
||||||
@@ -253,6 +260,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.quality = this.cookieService.get('metube_quality') || 'best';
|
this.quality = this.cookieService.get('metube_quality') || 'best';
|
||||||
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
|
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
|
||||||
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
||||||
|
this.sponsorblock = this.cookieService.get('metube_sponsorblock') === 'true';
|
||||||
// Will be set from backend configuration, use empty string as placeholder
|
// Will be set from backend configuration, use empty string as placeholder
|
||||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||||
this.clipStart = this.cookieService.get('metube_clip_start') || '';
|
this.clipStart = this.cookieService.get('metube_clip_start') || '';
|
||||||
@@ -351,13 +359,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
// workaround to allow fetching of Map values in the order they were inserted
|
// keyvalue comparator that preserves insertion order (Angular's keyvalue
|
||||||
// https://github.com/angular/angular/issues/31420
|
// pipe sorts by key by default): https://github.com/angular/angular/issues/31420
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
asIsOrder() {
|
asIsOrder() {
|
||||||
return 1;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
qualityChanged() {
|
qualityChanged() {
|
||||||
@@ -430,10 +435,16 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.downloads.configurationChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
this.downloads.configurationChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
next: (config: any) => {
|
next: (config: any) => {
|
||||||
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
|
const playlistItemLimit = parseInt(String(config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'] ?? '0'), 10);
|
||||||
if (playlistItemLimit !== '0') {
|
if (!Number.isNaN(playlistItemLimit) && playlistItemLimit > 0) {
|
||||||
this.playlistItemLimit = playlistItemLimit;
|
this.playlistItemLimit = playlistItemLimit;
|
||||||
}
|
}
|
||||||
|
// Pre-fill the download folder, unless the user has already typed one
|
||||||
|
// this session. The server drops DEFAULT_FOLDER when CUSTOM_DIRS is
|
||||||
|
// off, so there is nothing to guard against here.
|
||||||
|
if (!this.folder) {
|
||||||
|
this.folder = String(config['DEFAULT_FOLDER'] ?? '');
|
||||||
|
}
|
||||||
// Set chapter template from backend config if not already set by cookie
|
// Set chapter template from backend config if not already set by cookie
|
||||||
if (!this.chapterTemplate) {
|
if (!this.chapterTemplate) {
|
||||||
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
|
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
|
||||||
@@ -526,6 +537,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
return status?.status === 'error' ? status.msg || null : null;
|
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() {
|
private refreshSubscriptionsWithAlert() {
|
||||||
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
||||||
const error = this.getStatusError(refreshRes);
|
const error = this.getStatusError(refreshRes);
|
||||||
@@ -593,14 +612,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Subscriptions do not support clip ranges (backend rejects clip fields).
|
|
||||||
const { clipStart: _clipStart, clipEnd: _clipEnd, ...subscribeBase } = payload;
|
|
||||||
void _clipStart;
|
|
||||||
void _clipEnd;
|
|
||||||
this.subscribeInProgress = true;
|
this.subscribeInProgress = true;
|
||||||
this.subscriptionsSvc
|
this.subscriptionsSvc
|
||||||
.subscribe({
|
.subscribe({
|
||||||
...subscribeBase,
|
...payload,
|
||||||
checkIntervalMinutes: this.checkIntervalMinutes,
|
checkIntervalMinutes: this.checkIntervalMinutes,
|
||||||
titleRegex: tr,
|
titleRegex: tr,
|
||||||
skipSubscriberOnly: this.skipSubscriberOnly,
|
skipSubscriberOnly: this.skipSubscriberOnly,
|
||||||
@@ -658,6 +673,34 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginEditName(id: string, current: string | undefined) {
|
||||||
|
this.editingNameId = id;
|
||||||
|
this.nameEditDraft = current ?? '';
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEditName() {
|
||||||
|
this.editingNameId = null;
|
||||||
|
this.nameEditDraft = '';
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveName(id: string) {
|
||||||
|
const name = (this.nameEditDraft || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
this.toasts.error('Subscription name must not be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.subscriptionsSvc.update(id, { name }).subscribe((res) => {
|
||||||
|
const error = this.getStatusError(res);
|
||||||
|
if (error) {
|
||||||
|
this.toasts.error(error || 'Update subscription failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cancelEditName();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
deleteSubscription(id: string) {
|
deleteSubscription(id: string) {
|
||||||
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
@@ -814,6 +857,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sponsorblockChanged() {
|
||||||
|
this.cookieService.set('metube_sponsorblock', this.sponsorblock ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||||
|
}
|
||||||
|
|
||||||
splitByChaptersChanged() {
|
splitByChaptersChanged() {
|
||||||
this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||||
}
|
}
|
||||||
@@ -877,6 +924,22 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The format the download was queued with, labelled the way the form labels
|
||||||
|
// it, so a queued item can be told apart while it is still downloading.
|
||||||
|
formatLabel(download: Download): string {
|
||||||
|
const format = (download.format || '').trim();
|
||||||
|
if (!format) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const options: Option[] = [
|
||||||
|
...this.videoFormats,
|
||||||
|
...this.audioFormats,
|
||||||
|
...this.captionFormats,
|
||||||
|
...this.thumbnailFormats,
|
||||||
|
];
|
||||||
|
return options.find(o => o.id === format)?.text ?? format.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
formatCodecLabel(download: Download): string {
|
formatCodecLabel(download: Download): string {
|
||||||
if (download.download_type !== 'video') {
|
if (download.download_type !== 'video') {
|
||||||
const format = (download.format || '').toUpperCase();
|
const format = (download.format || '').toUpperCase();
|
||||||
@@ -1054,6 +1117,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit,
|
playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit,
|
||||||
autoStart: overrides.autoStart ?? this.autoStart,
|
autoStart: overrides.autoStart ?? this.autoStart,
|
||||||
splitByChapters: overrides.splitByChapters ?? this.splitByChapters,
|
splitByChapters: overrides.splitByChapters ?? this.splitByChapters,
|
||||||
|
sponsorblock: overrides.sponsorblock ?? this.sponsorblock,
|
||||||
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
|
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
|
||||||
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
|
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
|
||||||
subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
|
subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
|
||||||
@@ -1085,6 +1149,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
if (status.status === 'error' && !this.cancelRequested) {
|
if (status.status === 'error' && !this.cancelRequested) {
|
||||||
this.toasts.error(`Error adding URL: ${status.msg}`);
|
this.toasts.error(`Error adding URL: ${status.msg}`);
|
||||||
} else if (status.status !== 'error') {
|
} 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.addUrl = '';
|
||||||
}
|
}
|
||||||
this.resetAddState();
|
this.resetAddState();
|
||||||
@@ -1113,7 +1181,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadItemByKey(id: string) {
|
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 {
|
liveCountdownSeconds(download: Download): number | null {
|
||||||
@@ -1137,48 +1205,38 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retryDownload(key: string, download: Download) {
|
retryDownload(key: string, download: Download) {
|
||||||
this.addDownload({
|
// Only remove the done-list record once the retry is confirmed queued —
|
||||||
url: download.url,
|
// deleting it eagerly would silently lose history if the re-add fails.
|
||||||
downloadType: download.download_type,
|
this.downloads.retry(key)
|
||||||
codec: download.codec,
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
quality: download.quality,
|
.subscribe((status: Status) => {
|
||||||
format: download.format,
|
if (status.status === 'error') {
|
||||||
folder: download.folder,
|
this.toasts.error(`Error retrying ${download.title}: ${status.msg}`);
|
||||||
customNamePrefix: download.custom_name_prefix,
|
this.cdr.markForCheck();
|
||||||
playlistItemLimit: download.playlist_item_limit,
|
return;
|
||||||
autoStart: true,
|
}
|
||||||
splitByChapters: download.split_by_chapters,
|
this.downloads.delById('done', [key]).subscribe();
|
||||||
chapterTemplate: download.chapter_template,
|
});
|
||||||
subtitleLanguage: download.subtitle_language,
|
|
||||||
subtitleMode: download.subtitle_mode,
|
|
||||||
ytdlOptionsPresets: download.ytdl_options_presets?.length
|
|
||||||
? [...download.ytdl_options_presets]
|
|
||||||
: [],
|
|
||||||
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
|
|
||||||
clipStart: download.clip_start != null ? String(download.clip_start) : '',
|
|
||||||
clipEnd: download.clip_end != null ? String(download.clip_end) : '',
|
|
||||||
});
|
|
||||||
this.downloads.delById('done', [key]).subscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delDownload(where: State, id: string) {
|
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){
|
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) {
|
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() {
|
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() {
|
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() {
|
retryFailedDownloads() {
|
||||||
@@ -1506,6 +1564,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
result.reverse();
|
result.reverse();
|
||||||
}
|
}
|
||||||
this.cachedSortedDone = result;
|
this.cachedSortedDone = result;
|
||||||
|
this.cachedSortedDoneIds = result.map(([key]) => key);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleErrorDetail(id: string) {
|
toggleErrorDetail(id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,38 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||||
import { Checkable } from '../interfaces';
|
import { Checkable } from '../interfaces';
|
||||||
|
|
||||||
|
function makeList(ids: string[]): Map<string, Checkable> {
|
||||||
|
const list = new Map<string, Checkable>();
|
||||||
|
for (const id of ids) {
|
||||||
|
list.set(id, { checked: false });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMaster(list: Map<string, Checkable>, orderedIds: string[] | null = null) {
|
||||||
|
const fixture = TestBed.createComponent(SelectAllCheckboxComponent);
|
||||||
|
fixture.componentRef.setInput('id', 'queue');
|
||||||
|
fixture.componentRef.setInput('list', list);
|
||||||
|
if (orderedIds) {
|
||||||
|
fixture.componentRef.setInput('orderedIds', orderedIds);
|
||||||
|
}
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates what the item checkbox does: ngModel writes the new state, then
|
||||||
|
// the change handler reports the click to the master.
|
||||||
|
function clickItem(
|
||||||
|
master: SelectAllCheckboxComponent,
|
||||||
|
list: Map<string, Checkable>,
|
||||||
|
id: string,
|
||||||
|
shift = false,
|
||||||
|
) {
|
||||||
|
const item = list.get(id)!;
|
||||||
|
item.checked = !item.checked;
|
||||||
|
master.selectionChanged(id, shift);
|
||||||
|
}
|
||||||
|
|
||||||
describe('SelectAllCheckboxComponent', () => {
|
describe('SelectAllCheckboxComponent', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -20,4 +52,87 @@ describe('SelectAllCheckboxComponent', () => {
|
|||||||
fixture.componentInstance.clicked();
|
fixture.componentInstance.clicked();
|
||||||
expect(list.get('u1')?.checked).toBe(true);
|
expect(list.get('u1')?.checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shift-click checks every item between the two clicks', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4', 'u5']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u2');
|
||||||
|
clickItem(master, list, 'u4', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends upwards as well as downwards', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u4');
|
||||||
|
clickItem(master, list, 'u2', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shift-clicking a checked box clears the range', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3']);
|
||||||
|
list.forEach((item) => (item.checked = true));
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the rendered order, not the map order', () => {
|
||||||
|
// The done list renders newest-first, so its rendered order is not the
|
||||||
|
// order the entries sit in the map. u2 lies inside the range on screen
|
||||||
|
// and outside it in the map, which is what separates the two.
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4']);
|
||||||
|
const master = makeMaster(list, ['u4', 'u2', 'u3', 'u1']).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u4');
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
// u1 (rendered last) stays clear; u2 is swept up with the range.
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plain click after a range starts a new anchor', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
clickItem(master, list, 'u2', true);
|
||||||
|
clickItem(master, list, 'u4');
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([true, true, false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select-all clears the anchor so the next shift-click is a plain toggle', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3']);
|
||||||
|
const fixture = makeMaster(list);
|
||||||
|
const master = fixture.componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
master.selected = true;
|
||||||
|
master.clicked();
|
||||||
|
master.selected = false;
|
||||||
|
master.clicked();
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a range whose anchor row is gone', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
// The anchor finishes downloading and leaves the queue.
|
||||||
|
list.delete('u1');
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,17 +20,33 @@ import { FormsModule } from "@angular/forms";
|
|||||||
export class SelectAllCheckboxComponent {
|
export class SelectAllCheckboxComponent {
|
||||||
readonly id = input.required<string>();
|
readonly id = input.required<string>();
|
||||||
readonly list = input.required<Map<string, Checkable>>();
|
readonly list = input.required<Map<string, Checkable>>();
|
||||||
|
// The ids in the order the rows are rendered. The done list is sorted for
|
||||||
|
// display, so its order is not the map's insertion order, and a range
|
||||||
|
// selection has to follow what the user sees. Left unset, the map order is
|
||||||
|
// the rendered order.
|
||||||
|
readonly orderedIds = input<string[] | null>(null);
|
||||||
readonly changed = output<number>();
|
readonly changed = output<number>();
|
||||||
|
|
||||||
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
|
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
|
||||||
selected!: boolean;
|
selected!: boolean;
|
||||||
|
|
||||||
|
// The item a range extends from: the last one toggled on its own.
|
||||||
|
private anchorId: string | null = null;
|
||||||
|
|
||||||
clicked() {
|
clicked() {
|
||||||
this.list().forEach(item => item.checked = this.selected);
|
this.list().forEach(item => item.checked = this.selected);
|
||||||
|
// Select-all is not a position, so there is nothing to extend from next.
|
||||||
|
this.anchorId = null;
|
||||||
this.selectionChanged();
|
this.selectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionChanged() {
|
selectionChanged(id?: string, extend = false) {
|
||||||
|
if (id !== undefined) {
|
||||||
|
if (extend && this.anchorId !== null && this.anchorId !== id) {
|
||||||
|
this.applyRange(this.anchorId, id);
|
||||||
|
}
|
||||||
|
this.anchorId = id;
|
||||||
|
}
|
||||||
const masterCheckbox = this.masterCheckbox();
|
const masterCheckbox = this.masterCheckbox();
|
||||||
if (!masterCheckbox)
|
if (!masterCheckbox)
|
||||||
return;
|
return;
|
||||||
@@ -40,4 +56,27 @@ export class SelectAllCheckboxComponent {
|
|||||||
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
|
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
|
||||||
this.changed.emit(checked);
|
this.changed.emit(checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything between the anchor and the just-clicked row takes the state the
|
||||||
|
// click produced, so shift-clicking a checked box clears the range and
|
||||||
|
// shift-clicking an unchecked one fills it.
|
||||||
|
private applyRange(fromId: string, toId: string) {
|
||||||
|
const ids = this.orderedIds() ?? Array.from(this.list().keys());
|
||||||
|
const from = ids.indexOf(fromId);
|
||||||
|
const to = ids.indexOf(toId);
|
||||||
|
// A row can disappear between two clicks (a download finishing moves it
|
||||||
|
// from the queue to the done list); without both ends there is no range.
|
||||||
|
if (from < 0 || to < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = this.list().get(toId)?.checked ?? false;
|
||||||
|
const start = Math.min(from, to);
|
||||||
|
const end = Math.max(from, to);
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
const item = this.list().get(ids[i]);
|
||||||
|
if (item) {
|
||||||
|
item.checked = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,33 @@ describe('ItemCheckboxComponent', () => {
|
|||||||
itemFixture.detectChanges();
|
itemFixture.detectChanges();
|
||||||
expect(itemFixture.componentInstance).toBeTruthy();
|
expect(itemFixture.componentInstance).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports the shift modifier from the click to the master', () => {
|
||||||
|
const masterFixture = TestBed.createComponent(SelectAllCheckboxComponent);
|
||||||
|
masterFixture.componentRef.setInput('id', 'q');
|
||||||
|
masterFixture.componentRef.setInput('list', new Map());
|
||||||
|
masterFixture.detectChanges();
|
||||||
|
const master = masterFixture.componentInstance;
|
||||||
|
const reported: [string | undefined, boolean | undefined][] = [];
|
||||||
|
master.selectionChanged = (id?: string, extend?: boolean) => {
|
||||||
|
reported.push([id, extend]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemFixture = TestBed.createComponent(ItemCheckboxComponent);
|
||||||
|
itemFixture.componentRef.setInput('id', 'row1');
|
||||||
|
itemFixture.componentRef.setInput('master', master);
|
||||||
|
itemFixture.componentRef.setInput('checkable', { checked: false });
|
||||||
|
itemFixture.detectChanges();
|
||||||
|
const item = itemFixture.componentInstance;
|
||||||
|
|
||||||
|
item.clicked(new MouseEvent('click', { shiftKey: true }));
|
||||||
|
item.changed();
|
||||||
|
// The modifier must not stick to the next toggle.
|
||||||
|
item.changed();
|
||||||
|
|
||||||
|
expect(reported).toEqual([
|
||||||
|
['row1', true],
|
||||||
|
['row1', false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { FormsModule } from '@angular/forms';
|
|||||||
selector: 'app-item-checkbox',
|
selector: 'app-item-checkbox',
|
||||||
template: `
|
template: `
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()" [attr.aria-label]="'Select item ' + id()">
|
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (click)="clicked($event)" (change)="changed()" [attr.aria-label]="'Select item ' + id()">
|
||||||
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -22,4 +22,19 @@ export class ItemCheckboxComponent {
|
|||||||
readonly id = input.required<string>();
|
readonly id = input.required<string>();
|
||||||
readonly master = input.required<SelectAllCheckboxComponent>();
|
readonly master = input.required<SelectAllCheckboxComponent>();
|
||||||
readonly checkable = input.required<Checkable>();
|
readonly checkable = input.required<Checkable>();
|
||||||
|
|
||||||
|
// click fires before change, so the modifier is recorded here and read once
|
||||||
|
// ngModel has written the new state into the checkable. Keyboard activation
|
||||||
|
// fires change without a click, which is a plain toggle.
|
||||||
|
private extend = false;
|
||||||
|
|
||||||
|
clicked(event: MouseEvent) {
|
||||||
|
this.extend = event.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed() {
|
||||||
|
const extend = this.extend;
|
||||||
|
this.extend = false;
|
||||||
|
this.master().selectionChanged(this.id(), extend);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Download {
|
|||||||
custom_name_prefix: string;
|
custom_name_prefix: string;
|
||||||
playlist_item_limit: number;
|
playlist_item_limit: number;
|
||||||
split_by_chapters?: boolean;
|
split_by_chapters?: boolean;
|
||||||
|
sponsorblock?: boolean;
|
||||||
chapter_template?: string;
|
chapter_template?: string;
|
||||||
subtitle_language?: string;
|
subtitle_language?: string;
|
||||||
subtitle_mode?: string;
|
subtitle_mode?: string;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface SubscriptionRow {
|
|||||||
folder: string;
|
folder: string;
|
||||||
title_regex?: string;
|
title_regex?: string;
|
||||||
skip_subscriber_only?: boolean;
|
skip_subscriber_only?: boolean;
|
||||||
|
clip_start?: number | null;
|
||||||
|
clip_end?: number | null;
|
||||||
last_checked: number | null;
|
last_checked: number | null;
|
||||||
seen_count: number;
|
seen_count: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|||||||
@@ -10,15 +10,16 @@ describe('FileSizePipe', () => {
|
|||||||
it('formats bytes and larger units', () => {
|
it('formats bytes and larger units', () => {
|
||||||
const pipe = new FileSizePipe();
|
const pipe = new FileSizePipe();
|
||||||
expect(pipe.transform(500)).toContain('Bytes');
|
expect(pipe.transform(500)).toContain('Bytes');
|
||||||
expect(pipe.transform(1000)).toContain('KB');
|
expect(pipe.transform(1000)).toContain('Bytes');
|
||||||
expect(pipe.transform(1000 * 1000)).toContain('MB');
|
expect(pipe.transform(1024)).toContain('KB');
|
||||||
expect(pipe.transform(1000 ** 3)).toContain('GB');
|
expect(pipe.transform(1024 ** 2)).toContain('MB');
|
||||||
|
expect(pipe.transform(1024 ** 3)).toContain('GB');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles boundaries between units', () => {
|
it('handles boundaries between units', () => {
|
||||||
const pipe = new FileSizePipe();
|
const pipe = new FileSizePipe();
|
||||||
expect(pipe.transform(999)).toContain('Bytes');
|
expect(pipe.transform(1023)).toContain('Bytes');
|
||||||
expect(pipe.transform(1000)).toContain('KB');
|
expect(pipe.transform(1024)).toContain('KB');
|
||||||
expect(pipe.transform(1001)).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';
|
if (isNaN(value) || value === 0) return '0 Bytes';
|
||||||
|
|
||||||
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
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]}`;
|
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,7 @@ function basePayload(): AddDownloadPayload {
|
|||||||
playlistItemLimit: 0,
|
playlistItemLimit: 0,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
splitByChapters: false,
|
splitByChapters: false,
|
||||||
|
sponsorblock: false,
|
||||||
chapterTemplate: '',
|
chapterTemplate: '',
|
||||||
subtitleLanguage: 'en',
|
subtitleLanguage: 'en',
|
||||||
subtitleMode: 'prefer_manual',
|
subtitleMode: 'prefer_manual',
|
||||||
@@ -117,6 +118,14 @@ describe('DownloadsService', () => {
|
|||||||
req.flush({ presets: ['Preset A'] });
|
req.flush({ presets: ['Preset A'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retry() posts the failed download id', () => {
|
||||||
|
service.retry('https://example.com/v').subscribe();
|
||||||
|
const req = httpMock.expectOne('retry');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual({ id: 'https://example.com/v' });
|
||||||
|
req.flush({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
it('cancelAdd posts to cancel-add', () => {
|
it('cancelAdd posts to cancel-add', () => {
|
||||||
service.cancelAdd().subscribe();
|
service.cancelAdd().subscribe();
|
||||||
const req = httpMock.expectOne('cancel-add');
|
const req = httpMock.expectOne('cancel-add');
|
||||||
@@ -159,6 +168,61 @@ describe('DownloadsService', () => {
|
|||||||
req.flush({});
|
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 () => {
|
it('handleHTTPError extracts msg from object body', async () => {
|
||||||
const err = new HttpErrorResponse({
|
const err = new HttpErrorResponse({
|
||||||
error: { msg: 'bad' },
|
error: { msg: 'bad' },
|
||||||
@@ -226,6 +290,15 @@ describe('DownloadsService', () => {
|
|||||||
expect(updated?.deleting).toBe(true);
|
expect(updated?.deleting).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('socket updated ignores events for urls not already in the queue', () => {
|
||||||
|
expect(service.queue.has('unknown-url')).toBe(false);
|
||||||
|
socket.emit(
|
||||||
|
'updated',
|
||||||
|
JSON.stringify({ url: 'unknown-url', title: 't', status: 'downloading' }),
|
||||||
|
);
|
||||||
|
expect(service.queue.has('unknown-url')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('socket completed moves entry to done', () => {
|
it('socket completed moves entry to done', () => {
|
||||||
service.queue.set('u1', {
|
service.queue.set('u1', {
|
||||||
id: '1',
|
id: '1',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface AddDownloadPayload {
|
|||||||
playlistItemLimit: number;
|
playlistItemLimit: number;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
splitByChapters: boolean;
|
splitByChapters: boolean;
|
||||||
|
sponsorblock: boolean;
|
||||||
chapterTemplate: string;
|
chapterTemplate: string;
|
||||||
subtitleLanguage: string;
|
subtitleLanguage: string;
|
||||||
subtitleMode: string;
|
subtitleMode: string;
|
||||||
@@ -69,8 +70,14 @@ export class DownloadsService {
|
|||||||
.subscribe((strdata: string) => {
|
.subscribe((strdata: string) => {
|
||||||
const data: Download = JSON.parse(strdata);
|
const data: Download = JSON.parse(strdata);
|
||||||
const dl: Download | undefined = this.queue.get(data.url);
|
const dl: Download | undefined = this.queue.get(data.url);
|
||||||
data.checked = !!dl?.checked;
|
// An 'added' event always precedes legitimate updates. If the row is
|
||||||
data.deleting = !!dl?.deleting;
|
// 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.queue.set(data.url, data);
|
||||||
this.updated.next();
|
this.updated.next();
|
||||||
});
|
});
|
||||||
@@ -142,6 +149,7 @@ export class DownloadsService {
|
|||||||
playlist_item_limit: payload.playlistItemLimit,
|
playlist_item_limit: payload.playlistItemLimit,
|
||||||
auto_start: payload.autoStart,
|
auto_start: payload.autoStart,
|
||||||
split_by_chapters: payload.splitByChapters,
|
split_by_chapters: payload.splitByChapters,
|
||||||
|
sponsorblock: payload.sponsorblock,
|
||||||
chapter_template: payload.chapterTemplate,
|
chapter_template: payload.chapterTemplate,
|
||||||
subtitle_language: payload.subtitleLanguage,
|
subtitle_language: payload.subtitleLanguage,
|
||||||
subtitle_mode: payload.subtitleMode,
|
subtitle_mode: payload.subtitleMode,
|
||||||
@@ -163,8 +171,16 @@ export class DownloadsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public retry(id: string) {
|
||||||
|
return this.http.post<Status>('retry', { id: id }).pipe(
|
||||||
|
catchError(this.handleHTTPError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public startById(ids: string[]) {
|
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[]) {
|
public delById(where: State, ids: string[]) {
|
||||||
@@ -177,7 +193,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) {
|
public startByFilter(where: State, filter: (dl: Download) => boolean) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { SubscriptionsService, SubscribePayload } from './subscriptions.service';
|
||||||
|
import { MeTubeSocket } from './metube-socket.service';
|
||||||
|
|
||||||
|
class MeTubeSocketStub {
|
||||||
|
private subjects: Record<string, Subject<string>> = {};
|
||||||
|
|
||||||
|
fromEvent(event: string) {
|
||||||
|
if (!this.subjects[event]) {
|
||||||
|
this.subjects[event] = new Subject<string>();
|
||||||
|
}
|
||||||
|
return this.subjects[event].asObservable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function basePayload(): SubscribePayload {
|
||||||
|
return {
|
||||||
|
url: 'https://example.com/channel',
|
||||||
|
downloadType: 'video',
|
||||||
|
codec: 'auto',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
customNamePrefix: '',
|
||||||
|
playlistItemLimit: 0,
|
||||||
|
autoStart: true,
|
||||||
|
splitByChapters: false,
|
||||||
|
sponsorblock: false,
|
||||||
|
chapterTemplate: '',
|
||||||
|
subtitleLanguage: 'en',
|
||||||
|
subtitleMode: 'prefer_manual',
|
||||||
|
ytdlOptionsPresets: [],
|
||||||
|
ytdlOptionsOverrides: '',
|
||||||
|
clipStart: '',
|
||||||
|
clipEnd: '',
|
||||||
|
checkIntervalMinutes: 60,
|
||||||
|
titleRegex: '',
|
||||||
|
skipSubscriberOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SubscriptionsService', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let service: SubscriptionsService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
SubscriptionsService,
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: MeTubeSocket, useValue: new MeTubeSocketStub() },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
service = TestBed.inject(SubscriptionsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribe() carries the sponsorblock flag', () => {
|
||||||
|
service.subscribe({ ...basePayload(), sponsorblock: true }).subscribe();
|
||||||
|
const req = httpMock.expectOne('subscribe');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: true }));
|
||||||
|
req.flush({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribe() sends the flag off by default', () => {
|
||||||
|
service.subscribe(basePayload()).subscribe();
|
||||||
|
const req = httpMock.expectOne('subscribe');
|
||||||
|
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: false }));
|
||||||
|
req.flush({ status: 'ok' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,28 +81,35 @@ export class SubscriptionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscribe(payload: SubscribePayload) {
|
subscribe(payload: SubscribePayload) {
|
||||||
return this.http
|
const body: Record<string, unknown> = {
|
||||||
.post<Status>('subscribe', {
|
url: payload.url,
|
||||||
url: payload.url,
|
download_type: payload.downloadType,
|
||||||
download_type: payload.downloadType,
|
codec: payload.codec,
|
||||||
codec: payload.codec,
|
quality: payload.quality,
|
||||||
quality: payload.quality,
|
format: payload.format,
|
||||||
format: payload.format,
|
folder: payload.folder,
|
||||||
folder: payload.folder,
|
custom_name_prefix: payload.customNamePrefix,
|
||||||
custom_name_prefix: payload.customNamePrefix,
|
playlist_item_limit: payload.playlistItemLimit,
|
||||||
playlist_item_limit: payload.playlistItemLimit,
|
auto_start: payload.autoStart,
|
||||||
auto_start: payload.autoStart,
|
split_by_chapters: payload.splitByChapters,
|
||||||
split_by_chapters: payload.splitByChapters,
|
sponsorblock: payload.sponsorblock,
|
||||||
chapter_template: payload.chapterTemplate,
|
chapter_template: payload.chapterTemplate,
|
||||||
subtitle_language: payload.subtitleLanguage,
|
subtitle_language: payload.subtitleLanguage,
|
||||||
subtitle_mode: payload.subtitleMode,
|
subtitle_mode: payload.subtitleMode,
|
||||||
ytdl_options_presets: payload.ytdlOptionsPresets,
|
ytdl_options_presets: payload.ytdlOptionsPresets,
|
||||||
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||||
check_interval_minutes: payload.checkIntervalMinutes,
|
check_interval_minutes: payload.checkIntervalMinutes,
|
||||||
title_regex: payload.titleRegex,
|
title_regex: payload.titleRegex,
|
||||||
skip_subscriber_only: payload.skipSubscriberOnly,
|
skip_subscriber_only: payload.skipSubscriberOnly,
|
||||||
})
|
};
|
||||||
.pipe(catchError((err) => this.handleHTTPError(err)));
|
// Send the clip fields only when actually filled in. The backend treats an
|
||||||
|
// absent field as "not requested", which is what stops a t= timestamp on the
|
||||||
|
// subscribed URL from clipping every future download.
|
||||||
|
const cs = payload.clipStart?.trim();
|
||||||
|
const ce = payload.clipEnd?.trim();
|
||||||
|
if (cs) body['clip_start'] = cs;
|
||||||
|
if (ce) body['clip_end'] = ce;
|
||||||
|
return this.http.post<Status>('subscribe', body).pipe(catchError((err) => this.handleHTTPError(err)));
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(ids: string[]) {
|
delete(ids: string[]) {
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ requires-python = ">=3.13"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohappyeyeballs"
|
name = "aiohappyeyeballs"
|
||||||
version = "2.6.2"
|
version = "2.7.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
version = "3.14.1"
|
version = "3.14.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohappyeyeballs" },
|
{ name = "aiohappyeyeballs" },
|
||||||
@@ -24,72 +24,72 @@ dependencies = [
|
|||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
{ name = "yarl" },
|
{ name = "yarl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
|
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -106,14 +106,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.14.1"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -194,113 +194,199 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.6.17"
|
version = "2026.7.22"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cffi"
|
name = "cffi"
|
||||||
version = "2.0.0"
|
version = "2.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
{ 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/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||||
wheels = [
|
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/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||||
{ 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/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||||
{ 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/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||||
{ 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/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||||
{ 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/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||||
{ 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/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||||
{ 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/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||||
{ 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/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||||
{ 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/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||||
{ 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/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||||
{ 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/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||||
{ 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/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||||
{ 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/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||||
{ 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/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||||
{ 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/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||||
{ 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/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||||
{ 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/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||||
{ 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/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||||
{ 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/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||||
{ 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/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||||
{ 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/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||||
{ 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/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||||
{ 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/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||||
{ 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/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||||
{ 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/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||||
{ 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/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||||
{ 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/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||||
{ 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/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.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/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||||
{ 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/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||||
{ 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/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||||
{ 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/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||||
{ 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/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||||
{ 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/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.7"
|
version = "3.5.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||||
wheels = [
|
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/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
|
||||||
{ 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/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
|
||||||
{ 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/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
|
||||||
{ 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/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
|
||||||
{ 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/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
|
||||||
{ 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/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
|
||||||
{ 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/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
|
||||||
{ 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/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
|
||||||
{ 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/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
|
||||||
{ 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/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
|
||||||
{ 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/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
|
||||||
{ 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/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
|
||||||
{ 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/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
|
||||||
{ 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/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
|
||||||
{ 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/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
|
||||||
{ 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/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
|
||||||
{ 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/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
|
||||||
{ 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/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
|
||||||
{ 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/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
|
||||||
{ 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/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
|
||||||
{ 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/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
|
||||||
{ 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/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
|
||||||
{ 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/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
|
||||||
{ 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/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
|
||||||
{ 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/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
|
||||||
{ 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/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
|
||||||
{ 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/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
|
||||||
{ 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/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
|
||||||
{ 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/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
|
||||||
{ 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/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
|
||||||
{ 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/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
|
||||||
{ 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/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
|
||||||
{ 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/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
|
||||||
{ 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/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
|
||||||
{ 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/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
|
||||||
{ 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/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
|
||||||
{ 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/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
|
||||||
{ 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/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
|
||||||
{ 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/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
|
||||||
{ 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/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
|
||||||
{ 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/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
|
||||||
{ 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/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
|
||||||
{ 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/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
|
||||||
{ 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/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
|
||||||
{ 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/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
|
||||||
{ 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/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
|
||||||
{ 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/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
|
||||||
{ 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/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
|
||||||
{ 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/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -347,15 +433,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deno"
|
name = "deno"
|
||||||
version = "2.9.0"
|
version = "2.9.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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/26/27/06ad530f3be68dafdfae57457c534d5bb014cd6da58c2bd0f5b28f9ab8fa/deno-2.9.5.tar.gz", hash = "sha256:2f9681d7d2118a5e92e18915d1d88a6966fd6173ba4a8fb772f04080057e5a6a", size = 8166, upload-time = "2026-08-06T15:08:32.319Z" }
|
||||||
wheels = [
|
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/a8/99/1a1ff9e40e82d857b426a86d2159e3560000c2f98edb0f63170256582067/deno-2.9.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d321f54f82cb535904a108afd970c1c3e1db23716db1ab0cda5e0ca993dbf824", size = 42353187, upload-time = "2026-08-06T15:08:10.431Z" },
|
||||||
{ 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/ce/3a/d60dac70124d045dfc6f01bbd181e2865904ecca5710b2d319869ff59727/deno-2.9.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:696dffb666d6336a3527857b2f9b665db33b07883bb6adc3595dbb3b78748cf1", size = 38520058, upload-time = "2026-08-06T15:08:14.978Z" },
|
||||||
{ 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/a4/b4/5d462bc738b2172043a470ab97d7b6f3e7a287fa1916065e777e034ef1b1/deno-2.9.5-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bccec0965917b6daa323f9ce6ad8e884e0854506b0cdaec9de41bdac7620109a", size = 39908447, upload-time = "2026-08-06T15:08:19.751Z" },
|
||||||
{ 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/60/54/c6e0fb4b5e96dd7611bc75c8137046d7436666bd2e278f686e36d6a805af/deno-2.9.5-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e5aec0419739fd0359663a78765d4feda19d1d0b60a682d25add9b78e19f95b4", size = 41642333, upload-time = "2026-08-06T15:08:24.965Z" },
|
||||||
{ 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/7e/0b/3ca6468ec928d817ce6ef062ddfa11e71a3c68035c3401057d7cb33845f6/deno-2.9.5-py3-none-win_amd64.whl", hash = "sha256:570d4ee6f1ddb16d14848d36dac6cd2f586d8a0c94cf9c3e6b5da130da2597d5", size = 41615800, upload-time = "2026-08-06T15:08:30.015Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -637,20 +723,20 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.10.0"
|
version = "4.11.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -789,7 +875,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pylint"
|
name = "pylint"
|
||||||
version = "4.0.6"
|
version = "4.0.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "astroid" },
|
{ name = "astroid" },
|
||||||
@@ -800,9 +886,9 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "tomlkit" },
|
{ name = "tomlkit" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/de/92/98dace02f2d11b88160354c53944f77ea7327aa78bce1c75971e7aaa4347/pylint-4.0.7.tar.gz", hash = "sha256:9b2d1d15791c84b77a4fe2aafe8f0d9570717e2dea06d53b19c105cf60275a52", size = 1594770, upload-time = "2026-08-09T19:13:23.289Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/b0/3a8040e53df6c5c1e04b0e23ed53fdbeb64f333723a334d313fba2f581ce/pylint-4.0.7-py3-none-any.whl", hash = "sha256:be4a3111557a614411ed1fc89347ce4a8e1013a59e1f33d11485227a02e3304d", size = 539710, upload-time = "2026-08-09T19:13:21.228Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -849,27 +935,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-engineio"
|
name = "python-engineio"
|
||||||
version = "4.13.3"
|
version = "4.13.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "simple-websocket" },
|
{ name = "simple-websocket" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/df/d8/65cc479ab697a2e7fdee83a9bd8a06b61ec68bf763a58a302cf161bf38bb/python_engineio-4.13.5.tar.gz", hash = "sha256:b5764d62243e3ffbc4c76dda3d7897c329dc52294c80c27105f9faa054e76897", size = 80035, upload-time = "2026-08-12T22:52:23.78Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl", hash = "sha256:05c9f4951d242ad33d613b4245299562e5f64e4199f00e5390f9888505831704", size = 59963, upload-time = "2026-08-12T22:52:22.5Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-socketio"
|
name = "python-socketio"
|
||||||
version = "5.16.3"
|
version = "5.16.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bidict" },
|
{ name = "bidict" },
|
||||||
{ name = "python-engineio" },
|
{ name = "python-engineio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/06/5e/87d6b547c87c6d64f4a05f5bfaf6f42e9b786561216434290fdaa83f8667/python_socketio-5.16.4.tar.gz", hash = "sha256:f7fa4a43cc8e687930b5c6e44d6e2efc2071eca4bef49b8bb3dc0827f7f92235", size = 128140, upload-time = "2026-08-06T23:11:21.346Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl", hash = "sha256:0eb9c7687e7fbf59e60d714fd62afba77dfaf8ef8a06a0bff05a86c351accc2f", size = 82098, upload-time = "2026-08-06T23:11:19.851Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -914,11 +1000,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tomlkit"
|
name = "tomlkit"
|
||||||
version = "0.15.0"
|
version = "0.15.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1004,38 +1090,71 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "16.0"
|
version = "17.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" }
|
||||||
wheels = [
|
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/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" },
|
||||||
{ 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/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" },
|
||||||
{ 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/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" },
|
||||||
{ 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/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" },
|
||||||
{ 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/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" },
|
||||||
{ 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/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" },
|
||||||
{ 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/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" },
|
||||||
{ 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/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" },
|
||||||
{ 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/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" },
|
||||||
{ 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/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" },
|
||||||
{ 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/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" },
|
||||||
{ 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/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" },
|
||||||
{ 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/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" },
|
||||||
{ 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/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" },
|
||||||
{ 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/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" },
|
||||||
{ 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/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" },
|
||||||
{ 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/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" },
|
||||||
{ 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/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" },
|
||||||
{ 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/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" },
|
||||||
{ 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/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" },
|
||||||
{ 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/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" },
|
||||||
{ 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/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" },
|
||||||
{ 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/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" },
|
||||||
{ 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/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" },
|
||||||
{ 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/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" },
|
||||||
{ 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/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" },
|
||||||
{ 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/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" },
|
||||||
{ 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/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1052,76 +1171,76 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yarl"
|
name = "yarl"
|
||||||
version = "1.24.2"
|
version = "1.24.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "multidict" },
|
{ name = "multidict" },
|
||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" },
|
{ url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2026.6.9"
|
version = "2026.7.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/88/a4/1b0979d28f87774bb67fbbc66bce44f9dd1aa0e547a99e22985fac945c33/yt_dlp-2026.6.9.tar.gz", hash = "sha256:d50fcb95f48d61bedde33e408c1881d4c279e51c31354a599ce09e96ba0f4b86", size = 3030590, upload-time = "2026-06-09T23:27:14.831Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/ee/188a3dadf9dfdac713243521f919feca1cd091d4358c9ea7e8ebb710a7cc/yt_dlp-2026.6.9-py3-none-any.whl", hash = "sha256:442ba4c75724b9496144c8434b617962ee08d0ee7c26ec663848fe9b78d5a3e4", size = 3169035, upload-time = "2026-06-09T23:27:12.58Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user