diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0b5e99c3..bc6c7736 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -27,7 +27,7 @@ body: label: Command Used description: | The command that you typed that caused the error - placeholder: e.g. rip url https://example.com + placeholder: e.g. streamrip url https://example.com render: "bash" - type: textarea @@ -37,8 +37,8 @@ body: label: Debug Traceback description: | Run your command with the `-vvv` option and paste the output here. - For example, if the problematic command was `rip url https://example.com`, then - you would run `rip -vvv url https://example.com` to get the debug logs. + For example, if the problematic command was `streamrip url https://example.com`, then + you would run `streamrip -vvv url https://example.com` to get the debug logs. Make sure to check the logs for any personal information such as emails and remove them. render: "text" placeholder: Logs printed to terminal screen @@ -49,7 +49,7 @@ body: attributes: label: Config File description: | - Find the config file using `rip config open` and paste the contents here. + Find the config file using `streamrip config open` and paste the contents here. Make sure you REMOVE YOUR CREDENTIALS! render: toml placeholder: Contents of config.toml @@ -66,7 +66,7 @@ body: required: true attributes: label: streamrip version - description: Run `rip --version` to check. + description: Run `streamrip --version` to check. placeholder: e.g. 1.5 - type: textarea diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a26a5f4b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +# Dependabot handles security updates; Renovate (renovate.json) handles the +# regular version updates, SHA pinning and lock file maintenance. Keeping the +# version updates in one tool avoids two PRs for every release. +# +# open-pull-requests-limit: 0 turns Dependabot's version updates off without +# affecting security updates (enable "Dependabot security updates" in the +# repository settings under Code security). Raise the limit to let Dependabot +# open version updates as well. +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + target-branch: dev + schedule: + interval: weekly + timezone: Europe/Amsterdam + open-pull-requests-limit: 0 + labels: ["dependencies", "python"] + commit-message: + prefix: "fix(deps)" + + - package-ecosystem: github-actions + directory: "/" + target-branch: dev + schedule: + interval: weekly + timezone: Europe/Amsterdam + open-pull-requests-limit: 0 + labels: ["dependencies", "github-actions"] + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6a676aa7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: python -m pip install ruff + - run: ruff check . + - run: ruff format --check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Install Poetry + run: python -m pip install "poetry>=2.1,<3" + - name: Check that poetry.lock matches pyproject.toml + run: poetry check --lock + - name: Install dependencies + run: poetry install --no-interaction --all-extras + - name: Run tests + run: poetry run pytest + + # Installing the way users do: plain pip, on every platform. + install: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: python -m pip install . + - run: streamrip --version + - run: streamrip --help diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index e602ad7c..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - schedule: - - cron: '34 6 * * 4' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/poetry-publish.yml b/.github/workflows/poetry-publish.yml deleted file mode 100644 index 12370e9c..00000000 --- a/.github/workflows/poetry-publish.yml +++ /dev/null @@ -1,23 +0,0 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package - -on: - release: - types: [published] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Build and publish to pypi - uses: JRubics/poetry-publish@v1.17 - with: - pypi_token: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index 3b0ee92f..00000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Python Poetry Test - -on: - push: - branches: - - main - - dev - pull_request: - branches: - - main - - dev - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - name: Check out repository code - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' # Specify the Python version - - - name: Install and configure Poetry - uses: snok/install-poetry@v1 - with: - version: 1.5.1 - virtualenvs-create: false - virtualenvs-in-project: true - installer-parallel: true - - - name: Install dependencies - run: poetry install - - - name: Run tests - run: poetry run pytest - - - name: Success message - if: success() - run: echo "Tests passed successfully!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..812fded7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +# Builds the wheel and sdist for a published GitHub release and attaches them +# to it. The "streamrip" name on PyPI belongs to upstream, so this fork is +# installed from GitHub instead (see the README). +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: python -m pip install build + - run: python -m build + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" dist/* --clobber --repo "${{ github.repository }}" diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml deleted file mode 100644 index 3db4d545..00000000 --- a/.github/workflows/ruff.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Ruff -on: [push, pull_request] -jobs: - ruff: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: chartboost/ruff-action@v1 - - uses: chartboost/ruff-action@v1 - with: - args: 'format --check' diff --git a/.gitignore b/.gitignore index dd3443f5..58f05c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ StreamripDownloads /.mypy_cache .DS_Store pyrightconfig.json +.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ec58234d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,248 @@ +# Changelog + +All notable changes in this fork of [nathom/streamrip](https://github.com/nathom/streamrip). Numbers refer to +upstream issues and pull requests. + +## 2.4.2 + +- Albums and playlists download one at a time, with a spinner and status + line while a large one is still resolving. Track progress no longer mixes + two releases together, each track/album shows its format and quality + (`[FLAC 24B-96kHz]`), and the doubled-up, corrupted terminal output during + a long run is fixed. +- Tidal: an album it lists more than once under one artist -- a clean/explicit + pair, two quality tiers, or a "(Deluxe)"/"[Deluxe]" spelling of the same + edition -- downloads only its best copy instead of every copy. +- Tidal: requests held back together by a rate limit no longer all retry in + the same instant and immediately re-trip it. + +## 2.4.1 + +- `[metadata] prefer_explicit` is on by default: when an album or playlist + lists both a clean and an explicit copy of the same track, only the + explicit one is downloaded. Costs no extra API calls, only a later start + (every track is resolved before any of them download, instead of as each + one resolves). Set it to `false` in an existing config to keep both. + +## 2.4.0 + +### Installation and dependencies + +- Targets current Python (3.14+) only; every dependency is on a current + release with wheels for 3.14. Pillow was capped below 11, which has no 3.14 + wheels and failed to build ([#1032](https://github.com/nathom/streamrip/issues/1032), [#953](https://github.com/nathom/streamrip/issues/953), [#904](https://github.com/nathom/streamrip/issues/904)). +- `pyproject.toml` uses PEP 621 metadata (Poetry 2). +- `appdirs` and `aiodns` are no longer dependencies. `aiodns` forced Windows onto + an event loop that cannot run ffmpeg, so conversions failed there ([#729](https://github.com/nathom/streamrip/issues/729)). +- HTTP(S)_PROXY / ALL_PROXY environment variables are honoured ([#961](https://github.com/nathom/streamrip/issues/961)). + Every request goes through one kind of session, so `--no-ssl-verify` now + also covers the Qobuz app id lookup (it was ignored there), and last.fm + and that lookup time out on a stalled connection like the rest. A + certificate error explains the options in every command, not only in + `url` and `file`. +- Install from GitHub; the update notice points at this fork instead of telling + you to `pip install streamrip`, which would install upstream. +- The CLI command is `streamrip`, not `rip`: short names like `rip` collide + with other tools and local shell aliases/functions too easily. + +### Qobuz + +- Albums download again after Qobuz stopped returning track lists from + `album/get` and `playlist/get` ([#1012](https://github.com/nathom/streamrip/issues/1012), [PR #1013](https://github.com/nathom/streamrip/pull/1013)). +- Token login: the prompt asks for a user id and `user_auth_token` because the + password login is behind a captcha now ([#954](https://github.com/nathom/streamrip/issues/954), [#956](https://github.com/nathom/streamrip/issues/956), [#899](https://github.com/nathom/streamrip/issues/899), + [#854](https://github.com/nathom/streamrip/issues/854)), or streamrip opens a browser and takes them from your login + there ([PR #955](https://github.com/nathom/streamrip/pull/955)). +- A stale app id/secret is re-fetched automatically ([PR #1031](https://github.com/nathom/streamrip/pull/1031)); failed logins + close their session ([PR #1005](https://github.com/nathom/streamrip/pull/1005)); credentials are kept out of logs and errors + ([PR #1037](https://github.com/nathom/streamrip/pull/1037)). +- API errors (e.g. the intermittent Algolia search failure) are reported or + retried instead of `AssertionError` ([PR #1037](https://github.com/nathom/streamrip/pull/1037)). +- Free accounts can download albums they purchased ([#673](https://github.com/nathom/streamrip/issues/673), [PR #1007](https://github.com/nathom/streamrip/pull/1007)). +- Playlists longer than 500 tracks are complete ([PR #847](https://github.com/nathom/streamrip/pull/847)). +- The album edition ("Deluxe Edition", "2011 Remaster") is kept in the title, + tags and folder name, so editions no longer merge ([#902](https://github.com/nathom/streamrip/issues/902), [#867](https://github.com/nathom/streamrip/issues/867), + [PR #1027](https://github.com/nathom/streamrip/pull/1027)); `{version}` and `{tracktotal}` are available in `folder_format`. +- Single tracks of multi-disc albums go into their `Disc N` folder ([PR #1026](https://github.com/nathom/streamrip/pull/1026)). +- Missing performer/ISRC/genre no longer crash a track ([#668](https://github.com/nathom/streamrip/issues/668)). +- `download_booklets` works: an album's PDF booklets are saved in its folder. + The option was there (and on) but nothing downloaded them since v1. + +### Tidal + +- Lossless tracks download as FLAC again instead of AAC 320 ([#966](https://github.com/nathom/streamrip/issues/966), + [#897](https://github.com/nathom/streamrip/issues/897), [#968](https://github.com/nathom/streamrip/issues/968), + [PR #1017](https://github.com/nathom/streamrip/pull/1017)), and hi-res tracks + served as MPEG-DASH are downloaded and remuxed instead of silently downgrading to AAC + ([#974](https://github.com/nathom/streamrip/issues/974), [PR #998](https://github.com/nathom/streamrip/pull/998)). +- Highest quality first, then one step down at a time. Since late April 2026 no + single Tidal client is served everything ([#966](https://github.com/nathom/streamrip/issues/966)): the default one gets FLAC + 16/44.1 for every lossless release but never hi-res; the hi-res one gets 24-bit + FLAC (measured up to 192 kHz) for hi-res releases but only AAC 320 for the rest. + streamrip logs in with both (`hires_client`, now on by default: a second login, + asked for once) and asks the hi-res client first for every track Tidal doesn't + mark as lacking a hi-res master (the `HIRES_LOSSLESS` tag), then the default one. + A hi-res login from before is moved to its own config fields. It is only used + with `quality = 3`, so `--quality 2` needs no second login. +- New defaults ask for the best each source has: Tidal `quality = 3`, Qobuz + `quality = 4` (Deezer was already at FLAC). Existing configs keep their values. +- Folder names of hi-res albums show the real format (`[24B-96kHz]`) instead of + always `[16B-44.1kHz]`: Tidal's album data only says LOSSLESS, so the format is + read from the playback info of one track (one extra request per hi-res album). +- Tracks without lyrics are no longer dropped, and lyrics errors never abort a + track ([#983](https://github.com/nathom/streamrip/issues/983), [#959](https://github.com/nathom/streamrip/issues/959), [#866](https://github.com/nathom/streamrip/issues/866), [PR #1036](https://github.com/nathom/streamrip/pull/1036), [PR #1024](https://github.com/nathom/streamrip/pull/1024)). +- Share links ending in `/u` work ([PR #911](https://github.com/nathom/streamrip/pull/911)). +- Expired or revoked logins offer a fresh login instead of a traceback + ([#896](https://github.com/nathom/streamrip/issues/896), [#906](https://github.com/nathom/streamrip/issues/906), [#901](https://github.com/nathom/streamrip/issues/901), [PR #1029](https://github.com/nathom/streamrip/pull/1029)); refreshed tokens are saved. +- `null` copyright no longer crashes ([PR #979](https://github.com/nathom/streamrip/pull/979)); unknown quality values + (`HI_RES_LOSSLESS`) no longer raise `KeyError`. +- Search returned nothing when there was exactly one hit, which broke last.fm + playlists on Tidal. +- The "requested HI_RES but Tidal only has LOSSLESS" warning no longer appears + for `quality = 3`, which means "best available": it is only shown when + lossless was requested and a lossy stream came back. `hires_client` only + matters together with `quality = 3`. +- API requests retry connection errors, timeouts, 5xx and 429 (up to four + attempts with backoff) instead of dropping the track. A 429 pauses every + request and honours `Retry-After`. +- Albums with a `null` copyright no longer fail: the fix for it (upstream PR #979) + only reached the code for single tracks. Folder names say `FLAC`/`AAC` and + `44.1kHz` like the other sources, instead of `MP4` and `44100kHz`. +- Music videos in albums and playlists are left out instead of each failing as + a track (and being retried by `streamrip repair` forever); a playlist with + videos no longer loses its last tracks when paging. + +### Deezer + +- The quality is limited to what the subscription allows instead of failing with + "HiFi is required for quality 2" ([#1015](https://github.com/nathom/streamrip/issues/1015)); `--quality 3/4` no + longer raises `IndexError`. +- Playlists (including private ones) fall back to the gw API when the public API + refuses them ([#827](https://github.com/nathom/streamrip/issues/827), [PR #973](https://github.com/nathom/streamrip/pull/973), [PR #945](https://github.com/nathom/streamrip/pull/945)). +- New `link.deezer.com/s/...` share links ([#865](https://github.com/nathom/streamrip/issues/865), [#818](https://github.com/nathom/streamrip/issues/818), [PR #887](https://github.com/nathom/streamrip/pull/887)). +- Redirected album ids are followed ([#893](https://github.com/nathom/streamrip/issues/893)). +- Delisted tracks are recovered through their fallback track; the retired CDN is + no longer used ([PR #1028](https://github.com/nathom/streamrip/pull/1028)). +- Loved tracks (`/profile//loved`) can be downloaded ([PR #1008](https://github.com/nathom/streamrip/pull/1008), [PR #987](https://github.com/nathom/streamrip/pull/987)). +- Synced lyrics are embedded; `[downloads] lyrics` turns lyrics off ([PR #984](https://github.com/nathom/streamrip/pull/984)). +- All artists are credited ([#853](https://github.com/nathom/streamrip/issues/853), [PR #994](https://github.com/nathom/streamrip/pull/994), [PR #907](https://github.com/nathom/streamrip/pull/907)); missing fields no + longer crash parsing ([PR #944](https://github.com/nathom/streamrip/pull/944)). +- Connection pool warnings are gone ([PR #997](https://github.com/nathom/streamrip/pull/997)); album metadata is cached + ([PR #1000](https://github.com/nathom/streamrip/pull/1000)); blocking calls no longer stall other downloads. +- Tracks you uploaded to Deezer yourself (negative ids, no album) are skipped + in playlists and loved tracks instead of each failing ([PR #832](https://github.com/nathom/streamrip/pull/832)). +- Encrypted tracks are decrypted as they arrive instead of being held in memory + whole, and written in large chunks instead of 6 KB at a time; logging in no + longer fetches the account's profile a second time. + +### SoundCloud + +- The client id is found again ([#1038](https://github.com/nathom/streamrip/issues/1038)); tracks without an MP3 HLS stream use + the progressive stream or are skipped instead of crashing. +- HLS segments are reassembled in their original order regardless of which one + finishes downloading first; concurrent, out-of-order completion produced + skips and reordered audio ([#848](https://github.com/nathom/streamrip/issues/848), [#633](https://github.com/nathom/streamrip/issues/633)). +- Those segments are removed from the temp dir afterwards (also when one + fails) instead of piling up, and a failed join shows ffmpeg's error. +- Tracks are tagged disc 1 instead of disc 0. + +### Downloads, conversion and tagging + +- Failed downloads are no longer marked as downloaded, resolve failures are + recorded (whatever the error, for single tracks too), and `streamrip repair` + retries everything in the failed database + ([PR #1023](https://github.com/nathom/streamrip/pull/1023)). A track skipped + after a 403 (e.g. a geoblocked Deezer track in an otherwise public playlist) + no longer leaves the run in a state where cleanup trips over the file it + never wrote ([#677](https://github.com/nathom/streamrip/issues/677), [#701](https://github.com/nathom/streamrip/issues/701)). +- Up to four attempts with backoff; retries resume the partial file + ([#951](https://github.com/nathom/streamrip/issues/951), [#1022](https://github.com/nathom/streamrip/issues/1022), [PR #1009](https://github.com/nathom/streamrip/pull/1009)). Downloads run in a worker thread so they no + longer block each other ([PR #982](https://github.com/nathom/streamrip/pull/982)). +- A failed conversion keeps the original, says why (ffmpeg's own error), leaves + no temp file behind and still records the download + ([#1010](https://github.com/nathom/streamrip/issues/1010)); AAC falls back to ffmpeg's own encoder ([PR #990](https://github.com/nathom/streamrip/pull/990)); lossy + conversions honour `lossy_bitrate` ([#823](https://github.com/nathom/streamrip/issues/823), [PR #960](https://github.com/nathom/streamrip/pull/960)); OGG/Opus + keep cover art ([PR #992](https://github.com/nathom/streamrip/pull/992)); new AIFF target ([PR #1006](https://github.com/nathom/streamrip/pull/1006)); ffmpeg can no longer + break the terminal ([PR #996](https://github.com/nathom/streamrip/pull/996)). +- `[metadata] exclude` works ([#850](https://github.com/nathom/streamrip/issues/850)) and drops only the tags it names; MP3s are written as ID3v2.3 as intended. + M4A files get their composer tag (it was written to the year's atom and + lost), and MP3s no longer get the album description as their grouping + (TIT1) or an empty lyrics frame. +- `restrict_characters` applies to every folder streamrip names: a single + track saved with `add_singles_to_folder` lands in its album's folder + instead of one beside it, and playlist folders follow it too. +- Long titles no longer fail with "File name too long" ([#856](https://github.com/nathom/streamrip/issues/856), [#859](https://github.com/nathom/streamrip/issues/859), + [PR #860](https://github.com/nathom/streamrip/pull/860)); playlist names with `/` no longer create folders ([PR #1004](https://github.com/nathom/streamrip/pull/1004)). +- Artwork downloads work with current aiohttp headers ([#941](https://github.com/nathom/streamrip/issues/941)). +- `track_format` gains `{album}`, `{albumtitle}` and `{discnumber}` ([PR #826](https://github.com/nathom/streamrip/pull/826)). +- Error messages include the exception type ([#938](https://github.com/nathom/streamrip/issues/938)); Ctrl-C stops cleanly and + removes temporary artwork ([PR #1025](https://github.com/nathom/streamrip/pull/1025)). +- CDNs sending more than 100 headers no longer break downloads ([PR #1033](https://github.com/nathom/streamrip/pull/1033)). +- Tracks with multiple credited artists (Tidal, Deezer) get a real + multi-valued ARTIST tag (separate FLAC/Vorbis fields, a proper multi-value + MP4 atom, "/"-joined for ID3v2.3) instead of one "A, B" string that players + had to re-split themselves, and often got wrong. +- If a lossless copy of a track is already on disk, a lossy (AAC/MP3) copy + of it is no longer downloaded; if the lossy copy already exists and a + lossless one lands afterwards (also after a lossless conversion), the lossy + one is removed. Avoids ending up with both a FLAC and an AAC copy of the + same track after a quality setting change or a re-run. A file only counts + as a copy when its title and album tags match, not just its name, and ALAC + `.m4a` files are recognised as lossless. +- New `[metadata] prefer_explicit`: when an album or playlist lists both a + clean and an explicit copy of the same track, only the explicit one is + downloaded. Off by default. With it on, every track is resolved before the + first one starts downloading, so downloads start a bit later; the number + of API calls stays the same. Also fixed: Qobuz tracks were never marked + explicit (`parental_warning` was ignored), which this option depends on. +- A connection that goes quiet for 30 seconds times out (all sources) instead + of holding its album for aiohttp's default of five minutes. +- Artist downloads keep four albums in flight with a sliding window instead of + waiting for the slowest album of every batch of four, and one failing album no + longer aborts the rest. At most four tracks of an album are resolved at once, + so downloads start after the first few tracks instead of after the metadata, + lyrics and stream lookups of every track. +- An album whose tracks are all in the database is skipped without fetching a + cover or creating a folder, and skipped tracks are reported once per album + instead of once per track. +- New `streamrip database clear downloads|failed|all` forgets what was + downloaded, for when you deleted the files and want them again. +- An unknown release date gives the year "Unknown" instead of "Unkn" (in tags + and folder names), and no date tag instead of the text "Unknown". +- The repeats filter no longer crashes an artist download on an album whose + title starts with a bracket, e.g. "(What's the Story) Morning Glory?"; + `[qobuz_filters] non_albums` now actually skips single-track releases. +- Label downloads use the same sliding window as artists, and one failing album + no longer aborts the label. +- Playlists resolve through a sliding window too, instead of batches of 20 that + waited for their slowest track. A playlist track that fails is recorded for + `streamrip repair` like an album track. For last.fm playlists, one search + error no longer aborts the playlist, and falls back to the fallback source. +- A playlist downloaded with `set_playlist_to_album` shows up as one album in + Navidrome, Plex and the like: its tracks share the album artist "Various + Artists" and are marked as a compilation, instead of splitting into one + album per artist ([PR #738](https://github.com/nathom/streamrip/pull/738)). With `renumber_playlist_tracks`, the disc number + and track total follow the playlist too. +- One URL that fails to resolve no longer stops the others; `streamrip file` + keeps the order of its URLs; `search --first` with no results no longer + crashes; `database browse failed` lines its columns up with the headers. + +### Configuration + +- Options that never did anything are gone: `[tidal] download_videos`, + `[deezer] use_deezloader` and `deezloader_warnings`, the `[youtube]` section, + `[cli] text_output` and `[soundcloud] quality` (SoundCloud has one quality). +- Options that did the same thing are merged: `[downloads] concurrency = false` + is `max_connections = 1`; `[qobuz_filters]` is `[artist_filters]`, since it + applies to every source, and its `non_studio_albums` (extras plus + various-artists compilations) is part of `extras`. +- The config version is 2.3.3: an existing config is updated automatically on + the next run, keeping your settings, including those of merged options. +- `[cli] max_search_results` is used (it was ignored): it's the default for + `search --num-results`, in the interactive menu too. + +### Development + +- CI on Python 3.14, install smoke test on Linux, Windows and macOS, + CodeQL on the right branch; all actions pinned to SHAs. +- Renovate keeps dependencies current; Dependabot handles security updates. diff --git a/README.md b/README.md index 0df40b69..a2cbe147 100644 --- a/README.md +++ b/README.md @@ -1,189 +1,127 @@ -![streamrip logo](https://github.com/nathom/streamrip/blob/dev/demo/logo.svg?raw=true) +![streamrip logo](https://github.com/Stensel8/streamrip/blob/dev/demo/logo.svg?raw=true) -[![Downloads](https://pepy.tech/badge/streamrip)](https://pepy.tech/project/streamrip) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) +[![CI](https://github.com/Stensel8/streamrip/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/Stensel8/streamrip/actions/workflows/ci.yml) +[![Python 3.14+](https://img.shields.io/badge/python-3.14%2B-blue)](https://github.com/Stensel8/streamrip/blob/dev/pyproject.toml) +[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) -A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. +A scriptable music downloader for Qobuz, Tidal, Deezer and SoundCloud. -![downloading an album](https://github.com/nathom/streamrip/blob/dev/demo/download_album.png?raw=true) +> [!NOTE] +> A fork of [nathom/streamrip](https://github.com/nathom/streamrip) that works +> again: Python 3.14+, with most open upstream issues and pull requests worked +> through (see [CHANGELOG.md](CHANGELOG.md)). Streaming services change their +> APIs without notice, so it needs upkeep: it works now, with no promise that +> it stays maintained. -## Features +![downloading an album](https://github.com/Stensel8/streamrip/blob/dev/demo/download_album.png?raw=true) -- Fast, concurrent downloads powered by `aiohttp` -- Downloads tracks, albums, playlists, discographies, and labels from Qobuz, Tidal, Deezer, and SoundCloud -- Supports downloads of Spotify and Apple Music playlists through [last.fm](https://www.last.fm) -- Automatically converts files to a preferred format -- Has a database that stores the downloaded tracks' IDs so that repeats are avoided -- Concurrency and rate limiting -- Interactive search for all sources -- Highly customizable through the config file -- Integration with `youtube-dl` +- Tracks, albums, playlists, discographies and labels, several at once +- Spotify and Apple Music playlists through [last.fm](https://www.last.fm) +- Tagged, with cover art, and converted to FLAC, ALAC, AIFF, MP3, AAC, OGG or Opus if you like +- Remembers what it downloaded; `streamrip repair` retries what failed +- Interactive search on every source ## Installation -First, ensure [Python](https://www.python.org/downloads/) (version 3.10 or greater) and [pip](https://pip.pypa.io/en/stable/installing/) are installed. Then install `ffmpeg`. You may choose not to install this, but some functionality will be limited. +The install script fetches Python 3.14 (with [uv](https://docs.astral.sh/uv/)) +and installs streamrip into a `.venv` in the current directory: ```bash -pip3 install streamrip --upgrade +curl -LsSf https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.sh | bash # Linux/macOS ``` -If you run into issues, try installing directly from the `dev` branch: - -```bash -pip3 install git+https://github.com/nathom/streamrip.git@dev +```powershell +irm https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.ps1 | iex # Windows ``` -When you type +With Python 3.14 already there, install it into a venv of your own, or with +[pipx](https://pipx.pypa.io/) or uv: ```bash -rip -``` - -it should show the main help page. If you have no idea what these mean, or are having other issues installing, check out the [detailed installation instructions](https://github.com/nathom/streamrip/wiki#detailed-installation-instructions). +python3 -m venv .venv && source .venv/bin/activate # fish: activate.fish +pip install git+https://github.com/Stensel8/streamrip.git -For Arch Linux users, an AUR package exists. Make sure to install required packages from the AUR before using `makepkg` or use an AUR helper to automatically resolve them. -``` -git clone https://aur.archlinux.org/streamrip.git -cd streamrip -makepkg -si -``` - or - ``` -paru -S streamrip +pipx install git+https://github.com/Stensel8/streamrip.git +uv tool install git+https://github.com/Stensel8/streamrip.git ``` -Alternatively, for users of Homebrew, you can install streamrip through brew. -``` -brew install streamrip -``` +Add `@` to the URL for a specific version. Conversion and Tidal +hi-res downloads need [ffmpeg](https://ffmpeg.org/download.html) +(`apt install ffmpeg`, `brew install ffmpeg`). -### Streamrip beta +> [!IMPORTANT] +> `pip install streamrip`, the AUR package and `brew install streamrip` install +> **upstream** streamrip, whose last release doesn't install on Python 3.14. -If you want to get access to the latest and greatest features without waiting for a new release, install -from the `dev` branch with the following command +## Logging in -```bash -pip3 install git+https://github.com/nathom/streamrip.git@dev -``` +streamrip asks for what it needs the first time you use a source, and saves it +in the config. A login that stops working is asked for again. -## Example Usage +- **Qobuz** needs a subscription, or downloads the albums you bought. Its login + page has a captcha, so streamrip offers to open a browser and take the token + from your login there. Without a desktop, enter your user id and + `user_auth_token` yourself: log in at [play.qobuz.com](https://play.qobuz.com/login) + and copy both from the `user/login` response in DevTools → Network. +- **Tidal** needs a subscription. streamrip shows a link to log in with on any + device, twice: Tidal serves hi-res (up to 24-bit, 192 kHz) and CD quality + through two separate logins. Each track is asked for in hi-res first and falls + back to 16-bit FLAC when Tidal has no hi-res master. `hires_client = false` in + `[tidal]` skips the hi-res login and gets 16-bit FLAC only. +- **Deezer** needs the `arl` cookie of a logged-in session + ([how to find it](https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie)); + the quality follows your subscription. +- **SoundCloud** needs nothing. -**For Tidal and Qobuz, you NEED a premium subscription.** - -Download an album from Qobuz +## Usage ```bash -rip url https://www.qobuz.com/us-en/album/rumours-fleetwood-mac/0603497941032 +streamrip url https://www.qobuz.com/us-en/album/rumours-fleetwood-mac/0603497941032 # one or more URLs +streamrip file urls.txt # the URLs in a file +streamrip --codec mp3 url https://tidal.com/browse/album/147569387 +streamrip --quality 3 url https://tidal.com/browse/album/147569387 +streamrip search tidal album 'fleetwood mac rumours' # pick from the results +streamrip lastfm https://www.last.fm/user/nathan3895/playlists/12126195 +streamrip repair # retry what failed +streamrip config open # every option, explained ``` -Download multiple albums from Qobuz +By default streamrip asks every source for its highest quality and steps down, +one level at a time, when a track doesn't have it. `--quality` is the highest +quality to download (SoundCloud has one per track): -```bash -rip url https://www.qobuz.com/us-en/album/back-in-black-ac-dc/0886444889841 https://www.qobuz.com/us-en/album/blue-train-john-coltrane/0060253764852 -``` +| Quality | Audio | Sources | +| ------- | --------------------- | -------------------- | +| 0 | 128 kbps MP3 or AAC | Deezer, Tidal | +| 1 | 320 kbps MP3 or AAC | Deezer, Tidal, Qobuz | +| 2 | 16 bit, 44.1 kHz (CD) | Deezer, Tidal, Qobuz | +| 3 | 24 bit, ≤ 96 kHz | Tidal, Qobuz | +| 4 | 24 bit, ≤ 192 kHz | Qobuz | -Download the album and convert it to `mp3` +Every command has a `--help`. -```bash -rip --codec mp3 url https://open.qobuz.com/album/0060253780968 -``` +![streamrip interactive search](https://github.com/Stensel8/streamrip/blob/dev/demo/playlist_search.png?raw=true) -To set the maximum quality, use the `--quality` option to `0, 1, 2, 3, 4`: - -| Quality ID | Audio Quality | Available Sources | -| ---------- | --------------------- | -------------------------------------------- | -| 0 | 128 kbps MP3 or AAC | Deezer, Tidal, SoundCloud (most of the time) | -| 1 | 320 kbps MP3 or AAC | Deezer, Tidal, Qobuz, SoundCloud (rarely) | -| 2 | 16 bit, 44.1 kHz (CD) | Deezer, Tidal, Qobuz, SoundCloud (rarely) | -| 3 | 24 bit, ≤ 96 kHz | Tidal (MQA), Qobuz, SoundCloud (rarely) | -| 4 | 24 bit, ≤ 192 kHz | Qobuz | - -```bash -rip --quality 3 url https://tidal.com/browse/album/147569387 -``` - -> Using `4` is generally a waste of space. It is impossible for humans to perceive the difference between sampling rates higher than 44.1 kHz. It may be useful if you're processing/slowing down the audio. - -Search for playlists matching `rap` on Tidal - -```bash -rip search tidal playlist 'rap' -``` - -![streamrip interactive search](https://github.com/nathom/streamrip/blob/dev/demo/playlist_search.png?raw=true) - -Search for *Rumours* on Tidal, and download it - -```bash -rip search tidal album 'fleetwood mac rumours' -``` +## Contributing -Download a last.fm playlist using the lastfm command - -``` -rip lastfm https://www.last.fm/user/nathan3895/playlists/12126195 -``` - -For more customization, see the config file - -``` -rip config open -``` - -If you're confused about anything, see the help pages. The main help pages can be accessed by typing `rip` by itself in the command line. The help pages for each command can be accessed with the `--help` flag. For example, to see the help page for the `url` command, type - -``` -rip url --help -``` - -![example_help_page.png](https://github.com/nathom/streamrip/blob/dev/demo/example_help_page.png?raw=true) - -## Other information - -For more in-depth information about `streamrip`, see the help pages and the [wiki](https://github.com/nathom/streamrip/wiki/). - -## Contributions - -All contributions are appreciated! You can help out the project by opening an issue -or by submitting code. - -### Issues - -If you're opening an issue **use the Feature Request or Bug Report templates properly**. This ensures -that I have all of the information necessary to debug the issue. If you do not follow the templates, -**I will silently close the issue** and you'll have to deal with it yourself. - -### Code - -If you're new to Git, follow these steps to open your first Pull Request (PR): - -- Fork this repository -- Clone the new repository -- Commit your changes -- Open a pull request to the `dev` branch - -Please document any functions or obscure lines of code. - -### The Wiki - -To help out `streamrip` users that may be having trouble, consider contributing some information to the wiki. -Nothing is too obvious and everything is appreciated. +Report problems in the [issue tracker](https://github.com/Stensel8/streamrip/issues), +with the Bug Report or Feature Request template. For code, `poetry install --all-extras` +sets things up; `poetry run pytest` and `poetry run ruff check . && poetry run ruff format .` +should pass before you open a pull request to `dev`. ## Acknowledgements -Thanks to Vitiko98, Sorrow446, and DashLt for their contributions to this project, and the previous projects that made this one possible. - -`streamrip` was inspired by: - -- [qobuz-dl](https://github.com/vitiko98/qobuz-dl) -- [Qo-DL Reborn](https://github.com/badumbass/Qo-DL-Reborn) -- [Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) -- [scdl](https://github.com/flyingrub/scdl) +streamrip was written by [nathom](https://github.com/nathom) (consider +[sponsoring nathom](https://github.com/sponsors/nathom)); this fork builds on +everyone who sent fixes upstream, credited in [CHANGELOG.md](CHANGELOG.md). +Thanks to Vitiko98, Sorrow446 and DashLt for their contributions. streamrip was +inspired by [rip](https://github.com/nathom/streamrip), +[qobuz-dl](https://github.com/vitiko98/qobuz-dl), +[Qo-DL Reborn](https://github.com/badumbass/Qo-DL-Reborn), +[Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) +and [scdl](https://github.com/flyingrub/scdl). ## Disclaimer -I will not be responsible for how **you** use `streamrip`. By using `streamrip`, you agree to the terms and conditions of the Qobuz, Tidal, and Deezer APIs. - -## Sponsorship - -Consider becoming a Github sponsor for me if you enjoy my open source software. +I will not be responsible for how **you** use streamrip. By using streamrip, you +agree to the terms and conditions of the Qobuz, Tidal, and Deezer APIs. diff --git a/demo/example_help_page.png b/demo/example_help_page.png deleted file mode 100644 index f92448b8..00000000 Binary files a/demo/example_help_page.png and /dev/null differ diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..9550a3ae --- /dev/null +++ b/install.ps1 @@ -0,0 +1,39 @@ +# Installs streamrip (this fork) for Windows in one step, into an explicit +# .venv in the current directory -- never system Python, and not a global +# shim either. +# +# Uses uv (https://docs.astral.sh/uv/) to fetch Python 3.14 if it isn't +# already installed, then creates .venv and installs streamrip into it. +# Safe to re-run: it reuses the existing .venv and upgrades streamrip. + +if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + Write-Host "uv not found, installing it (see https://docs.astral.sh/uv/)..." + powershell -ExecutionPolicy ByPass -Command "irm https://astral.sh/uv/install.ps1 | iex" + $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" +} + +if (-not (Test-Path .venv)) { + Write-Host "Creating .venv (Python 3.14)..." + uv venv --python 3.14 .venv +} + +Write-Host "Installing streamrip into .venv..." +uv pip install --python .venv --upgrade ` + "streamrip @ git+https://github.com/Stensel8/streamrip.git" + +Write-Host "" +$version = & .venv\Scripts\streamrip.exe --version 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Host "Done. $version is installed at .venv\Scripts\streamrip.exe." +} else { + Write-Host "Install finished, but .venv\Scripts\streamrip.exe --version failed -- something's wrong." + exit 1 +} +Write-Host "" +Write-Host "Activate the venv so plain 'streamrip' works:" +Write-Host " .venv\Scripts\Activate.ps1" +Write-Host "Leave it again with 'deactivate'." +Write-Host "" +Write-Host "If 'streamrip' then runs something else, an alias or function named" +Write-Host "streamrip is shadowing it (check with 'Get-Command streamrip -All')." +Write-Host ".venv\Scripts\streamrip.exe always works regardless of that, activated or not." diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..df0422c4 --- /dev/null +++ b/install.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Installs streamrip (this fork) for Linux and macOS in one step, into an +# explicit .venv in the current directory -- never system Python, and not +# a global shim either. +# +# Uses uv (https://docs.astral.sh/uv/) to fetch Python 3.14 if it isn't +# already installed, then creates .venv and installs streamrip into it. +# Safe to re-run: it reuses the existing .venv and upgrades streamrip. +set -euo pipefail + +if ! command -v uv >/dev/null 2>&1; then + echo "uv not found, installing it (see https://docs.astral.sh/uv/)..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +if [ ! -d .venv ]; then + echo "Creating .venv (Python 3.14)..." + uv venv --python 3.14 .venv +fi + +echo "Installing streamrip into .venv..." +uv pip install --python .venv --upgrade \ + "streamrip @ git+https://github.com/Stensel8/streamrip.git" + +echo +if .venv/bin/streamrip --version >/dev/null 2>&1; then + echo "Done. $(.venv/bin/streamrip --version) is installed at .venv/bin/streamrip." +else + echo "Install finished, but .venv/bin/streamrip --version failed -- something's wrong." + exit 1 +fi +echo +# A script run via `bash` or `curl | bash` executes in a child process, so it +# cannot activate a venv in the shell you're typing into -- that one command +# is unavoidable. $SHELL is your *login* shell, not necessarily the one +# running this, so it's not reliable here (e.g. login shell zsh, actually +# running fish); the parent process is. +parent_shell=$(ps -o comm= -p "$PPID" 2>/dev/null || true) +echo "Activate the venv so plain 'streamrip' works:" +if [ "$parent_shell" = "fish" ]; then + echo " source .venv/bin/activate.fish" +else + echo " source .venv/bin/activate # bash/zsh" + echo " source .venv/bin/activate.fish # fish" +fi +echo "Leave it again with 'deactivate'." +echo +echo "If 'streamrip' then runs something else, a shell alias or function named" +echo "streamrip is shadowing it (check with 'command -v streamrip', or 'type" +echo "streamrip' in fish). .venv/bin/streamrip always works regardless of" +echo "that, activated or not." diff --git a/poetry.lock b/poetry.lock index e1814bd8..a8b6796c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,139 +1,161 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "aiodns" -version = "3.2.0" -description = "Simple DNS resolver for asyncio" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "aiodns-3.2.0-py3-none-any.whl", hash = "sha256:e443c0c27b07da3174a109fd9e736d69058d808f144d3c9d56dbd1776964c5f5"}, - {file = "aiodns-3.2.0.tar.gz", hash = "sha256:62869b23409349c21b072883ec8998316b234c9a9e36675756e8e317e8768f72"}, -] - -[package.dependencies] -pycares = ">=4.0.0" +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiofiles" -version = "0.7.0" +version = "25.1.0" description = "File support for asyncio." optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"}, - {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"}, + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, ] [[package]] name = "aiohappyeyeballs" -version = "2.5.0" +version = "2.7.1" description = "Happy Eyeballs for asyncio" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "aiohappyeyeballs-2.5.0-py3-none-any.whl", hash = "sha256:0850b580748c7071db98bffff6d4c94028d0d3035acc20fd721a0ce7e8cac35d"}, - {file = "aiohappyeyeballs-2.5.0.tar.gz", hash = "sha256:18fde6204a76deeabc97c48bdd01d5801cfda5d6b9c8bbeb1aaaee9d648ca191"}, + {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, + {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, ] [[package]] name = "aiohttp" -version = "3.11.13" +version = "3.14.3" description = "Async http client/server framework (asyncio)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a4fe27dbbeec445e6e1291e61d61eb212ee9fed6e47998b27de71d70d3e8777d"}, - {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e64ca2dbea28807f8484c13f684a2f761e69ba2640ec49dacd342763cc265ef"}, - {file = "aiohttp-3.11.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9840be675de208d1f68f84d578eaa4d1a36eee70b16ae31ab933520c49ba1325"}, - {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28a772757c9067e2aee8a6b2b425d0efaa628c264d6416d283694c3d86da7689"}, - {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b88aca5adbf4625e11118df45acac29616b425833c3be7a05ef63a6a4017bfdb"}, - {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce10ddfbe26ed5856d6902162f71b8fe08545380570a885b4ab56aecfdcb07f4"}, - {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa48dac27f41b36735c807d1ab093a8386701bbf00eb6b89a0f69d9fa26b3671"}, - {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89ce611b1eac93ce2ade68f1470889e0173d606de20c85a012bfa24be96cf867"}, - {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78e4dd9c34ec7b8b121854eb5342bac8b02aa03075ae8618b6210a06bbb8a115"}, - {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:66047eacbc73e6fe2462b77ce39fc170ab51235caf331e735eae91c95e6a11e4"}, - {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ad8f1c19fe277eeb8bc45741c6d60ddd11d705c12a4d8ee17546acff98e0802"}, - {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64815c6f02e8506b10113ddbc6b196f58dbef135751cc7c32136df27b736db09"}, - {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:967b93f21b426f23ca37329230d5bd122f25516ae2f24a9cea95a30023ff8283"}, - {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf1f31f83d16ec344136359001c5e871915c6ab685a3d8dee38e2961b4c81730"}, - {file = "aiohttp-3.11.13-cp310-cp310-win32.whl", hash = "sha256:00c8ac69e259c60976aa2edae3f13d9991cf079aaa4d3cd5a49168ae3748dee3"}, - {file = "aiohttp-3.11.13-cp310-cp310-win_amd64.whl", hash = "sha256:90d571c98d19a8b6e793b34aa4df4cee1e8fe2862d65cc49185a3a3d0a1a3996"}, - {file = "aiohttp-3.11.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b35aab22419ba45f8fc290d0010898de7a6ad131e468ffa3922b1b0b24e9d2e"}, - {file = "aiohttp-3.11.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81cba651db8795f688c589dd11a4fbb834f2e59bbf9bb50908be36e416dc760"}, - {file = "aiohttp-3.11.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f55d0f242c2d1fcdf802c8fabcff25a9d85550a4cf3a9cf5f2a6b5742c992839"}, - {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4bea08a6aad9195ac9b1be6b0c7e8a702a9cec57ce6b713698b4a5afa9c2e33"}, - {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6070bcf2173a7146bb9e4735b3c62b2accba459a6eae44deea0eb23e0035a23"}, - {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:718d5deb678bc4b9d575bfe83a59270861417da071ab44542d0fcb6faa686636"}, - {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f6b2c5b4a4d22b8fb2c92ac98e0747f5f195e8e9448bfb7404cd77e7bfa243f"}, - {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:747ec46290107a490d21fe1ff4183bef8022b848cf9516970cb31de6d9460088"}, - {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:01816f07c9cc9d80f858615b1365f8319d6a5fd079cd668cc58e15aafbc76a54"}, - {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a08ad95fcbd595803e0c4280671d808eb170a64ca3f2980dd38e7a72ed8d1fea"}, - {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c97be90d70f7db3aa041d720bfb95f4869d6063fcdf2bb8333764d97e319b7d0"}, - {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ab915a57c65f7a29353c8014ac4be685c8e4a19e792a79fe133a8e101111438e"}, - {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:35cda4e07f5e058a723436c4d2b7ba2124ab4e0aa49e6325aed5896507a8a42e"}, - {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:af55314407714fe77a68a9ccaab90fdb5deb57342585fd4a3a8102b6d4370080"}, - {file = "aiohttp-3.11.13-cp311-cp311-win32.whl", hash = "sha256:42d689a5c0a0c357018993e471893e939f555e302313d5c61dfc566c2cad6185"}, - {file = "aiohttp-3.11.13-cp311-cp311-win_amd64.whl", hash = "sha256:b73a2b139782a07658fbf170fe4bcdf70fc597fae5ffe75e5b67674c27434a9f"}, - {file = "aiohttp-3.11.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2eabb269dc3852537d57589b36d7f7362e57d1ece308842ef44d9830d2dc3c90"}, - {file = "aiohttp-3.11.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b77ee42addbb1c36d35aca55e8cc6d0958f8419e458bb70888d8c69a4ca833d"}, - {file = "aiohttp-3.11.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55789e93c5ed71832e7fac868167276beadf9877b85697020c46e9a75471f55f"}, - {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c929f9a7249a11e4aa5c157091cfad7f49cc6b13f4eecf9b747104befd9f56f2"}, - {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d33851d85537bbf0f6291ddc97926a754c8f041af759e0aa0230fe939168852b"}, - {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9229d8613bd8401182868fe95688f7581673e1c18ff78855671a4b8284f47bcb"}, - {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669dd33f028e54fe4c96576f406ebb242ba534dd3a981ce009961bf49960f117"}, - {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c1b20a1ace54af7db1f95af85da530fe97407d9063b7aaf9ce6a32f44730778"}, - {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5724cc77f4e648362ebbb49bdecb9e2b86d9b172c68a295263fa072e679ee69d"}, - {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:aa36c35e94ecdb478246dd60db12aba57cfcd0abcad43c927a8876f25734d496"}, - {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9b5b37c863ad5b0892cc7a4ceb1e435e5e6acd3f2f8d3e11fa56f08d3c67b820"}, - {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e06cf4852ce8c4442a59bae5a3ea01162b8fcb49ab438d8548b8dc79375dad8a"}, - {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5194143927e494616e335d074e77a5dac7cd353a04755330c9adc984ac5a628e"}, - {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:afcb6b275c2d2ba5d8418bf30a9654fa978b4f819c2e8db6311b3525c86fe637"}, - {file = "aiohttp-3.11.13-cp312-cp312-win32.whl", hash = "sha256:7104d5b3943c6351d1ad7027d90bdd0ea002903e9f610735ac99df3b81f102ee"}, - {file = "aiohttp-3.11.13-cp312-cp312-win_amd64.whl", hash = "sha256:47dc018b1b220c48089b5b9382fbab94db35bef2fa192995be22cbad3c5730c8"}, - {file = "aiohttp-3.11.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9862d077b9ffa015dbe3ce6c081bdf35135948cb89116e26667dd183550833d1"}, - {file = "aiohttp-3.11.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbfef0666ae9e07abfa2c54c212ac18a1f63e13e0760a769f70b5717742f3ece"}, - {file = "aiohttp-3.11.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a1f7d857c4fcf7cabb1178058182c789b30d85de379e04f64c15b7e88d66fb"}, - {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba40b7ae0f81c7029583a338853f6607b6d83a341a3dcde8bed1ea58a3af1df9"}, - {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5b95787335c483cd5f29577f42bbe027a412c5431f2f80a749c80d040f7ca9f"}, - {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7d474c5c1f0b9405c1565fafdc4429fa7d986ccbec7ce55bc6a330f36409cad"}, - {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e83fb1991e9d8982b3b36aea1e7ad27ea0ce18c14d054c7a404d68b0319eebb"}, - {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4586a68730bd2f2b04a83e83f79d271d8ed13763f64b75920f18a3a677b9a7f0"}, - {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fe4eb0e7f50cdb99b26250d9328faef30b1175a5dbcfd6d0578d18456bac567"}, - {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2a8a6bc19818ac3e5596310ace5aa50d918e1ebdcc204dc96e2f4d505d51740c"}, - {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f27eec42f6c3c1df09cfc1f6786308f8b525b8efaaf6d6bd76c1f52c6511f6a"}, - {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2a4a13dfbb23977a51853b419141cd0a9b9573ab8d3a1455c6e63561387b52ff"}, - {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:02876bf2f69b062584965507b07bc06903c2dc93c57a554b64e012d636952654"}, - {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b992778d95b60a21c4d8d4a5f15aaab2bd3c3e16466a72d7f9bfd86e8cea0d4b"}, - {file = "aiohttp-3.11.13-cp313-cp313-win32.whl", hash = "sha256:507ab05d90586dacb4f26a001c3abf912eb719d05635cbfad930bdbeb469b36c"}, - {file = "aiohttp-3.11.13-cp313-cp313-win_amd64.whl", hash = "sha256:5ceb81a4db2decdfa087381b5fc5847aa448244f973e5da232610304e199e7b2"}, - {file = "aiohttp-3.11.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:51c3ff9c7a25f3cad5c09d9aacbc5aefb9267167c4652c1eb737989b554fe278"}, - {file = "aiohttp-3.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e271beb2b1dabec5cd84eb488bdabf9758d22ad13471e9c356be07ad139b3012"}, - {file = "aiohttp-3.11.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e9eb7e5764abcb49f0e2bd8f5731849b8728efbf26d0cac8e81384c95acec3f"}, - {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baae005092e3f200de02699314ac8933ec20abf998ec0be39448f6605bce93df"}, - {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1982c98ac62c132d2b773d50e2fcc941eb0b8bad3ec078ce7e7877c4d5a2dce7"}, - {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2b25b2eeb35707113b2d570cadc7c612a57f1c5d3e7bb2b13870fe284e08fc0"}, - {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b27961d65639128336b7a7c3f0046dcc62a9443d5ef962e3c84170ac620cec47"}, - {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a01fe9f1e05025eacdd97590895e2737b9f851d0eb2e017ae9574d9a4f0b6252"}, - {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa1fb1b61881c8405829c50e9cc5c875bfdbf685edf57a76817dfb50643e4a1a"}, - {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:25de43bb3cf83ad83efc8295af7310219af6dbe4c543c2e74988d8e9c8a2a917"}, - {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe7065e2215e4bba63dc00db9ae654c1ba3950a5fff691475a32f511142fcddb"}, - {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7836587eef675a17d835ec3d98a8c9acdbeb2c1d72b0556f0edf4e855a25e9c1"}, - {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:85fa0b18558eb1427090912bd456a01f71edab0872f4e0f9e4285571941e4090"}, - {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a86dc177eb4c286c19d1823ac296299f59ed8106c9536d2b559f65836e0fb2c6"}, - {file = "aiohttp-3.11.13-cp39-cp39-win32.whl", hash = "sha256:684eea71ab6e8ade86b9021bb62af4bf0881f6be4e926b6b5455de74e420783a"}, - {file = "aiohttp-3.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:82c249f2bfa5ecbe4a1a7902c81c0fba52ed9ebd0176ab3047395d02ad96cfcb"}, - {file = "aiohttp-3.11.13.tar.gz", hash = "sha256:8ce789231404ca8fff7f693cdce398abf6d90fd5dae2b1847477196c243b1fbb"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a"}, + {file = "aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479"}, + {file = "aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f"}, + {file = "aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32"}, + {file = "aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e"}, + {file = "aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c"}, + {file = "aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a"}, + {file = "aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db"}, + {file = "aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910"}, + {file = "aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7"}, + {file = "aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa"}, + {file = "aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d"}, + {file = "aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228"}, + {file = "aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42"}, + {file = "aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d"}, + {file = "aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19"}, + {file = "aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559"}, + {file = "aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a"}, + {file = "aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c"}, + {file = "aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86"}, + {file = "aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c"}, + {file = "aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0"}, + {file = "aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5"}, + {file = "aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e"}, + {file = "aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71"}, + {file = "aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0"}, + {file = "aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883"}, + {file = "aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2"}, + {file = "aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062"}, + {file = "aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7"}, + {file = "aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646"}, + {file = "aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147"}, + {file = "aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41"}, + {file = "aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf"}, + {file = "aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da"}, + {file = "aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100"}, + {file = "aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0"}, + {file = "aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85"}, + {file = "aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9"}, + {file = "aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b"}, + {file = "aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7"}, + {file = "aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc"}, ] [package.dependencies] -aiohappyeyeballs = ">=2.3.0" -aiosignal = ">=1.1.2" -async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" @@ -141,336 +163,253 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\" and sys_platform != \"android\" and sys_platform != \"ios\"", "aiodns (>=3.3.0) ; sys_platform != \"android\" and sys_platform != \"ios\"", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\" and sys_platform != \"android\" and sys_platform != \"ios\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] [[package]] name = "aiolimiter" -version = "1.2.1" +version = "1.3.0" description = "asyncio rate limiter, a leaky bucket implementation" optional = false -python-versions = "<4.0,>=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7"}, - {file = "aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9"}, + {file = "aiolimiter-1.3.0-py3-none-any.whl", hash = "sha256:c0c16c377049fb2e40cc3373770e29c063de32aa25d84e5db168c854da6462b7"}, + {file = "aiolimiter-1.3.0.tar.gz", hash = "sha256:7343008c2228e89def7d4ce29ab98ee98822bf5db69018c09c90088929f7c104"}, ] [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] [package.dependencies] frozenlist = ">=1.1.0" -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - [[package]] name = "attrs" -version = "25.1.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, - {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - -[[package]] -name = "black" -version = "24.10.0" -description = "The uncompromising code formatter." -optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "certifi" -version = "2025.1.31" +version = "2026.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, -] - -[[package]] -name = "cffi" -version = "1.17.1" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.7" +groups = ["main", "dev"] files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] -[package.dependencies] -pycparser = "*" - [[package]] name = "charset-normalizer" -version = "3.4.1" +version = "3.5.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win32.whl", hash = "sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win_arm64.whl", hash = "sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8"}, + {file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"}, + {file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"}, ] [[package]] name = "click" -version = "8.1.8" +version = "8.5.0" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, + {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"}, + {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"}, ] -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "click-help-colors" version = "0.9.4" @@ -495,8 +434,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -504,233 +443,319 @@ files = [ [[package]] name = "deezer-py" -version = "1.3.6" +version = "1.3.7" description = "A wrapper for all Deezer's APIs" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "deezer-py-1.3.6.tar.gz", hash = "sha256:a3ef151f7971d69769e7393f71373eaf896bccd22167213872ae46e04e14a2d7"}, - {file = "deezer_py-1.3.6-py3-none-any.whl", hash = "sha256:e1dcb137246cfd009400657ec07d7269a491dace210a496fe73d0e18e4690206"}, + {file = "deezer-py-1.3.7.tar.gz", hash = "sha256:b1a332f88780cba1fd4a04bc5c79d9f649457ab1b2afebd0aa1b82b629a06c4a"}, + {file = "deezer_py-1.3.7-py3-none-any.whl", hash = "sha256:7b96edbde85aeef59e234d789a8f95d39f7035bd3e8e63c5a232e5c3dab1ad32"}, ] [package.dependencies] requests = "*" -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "flake8" -version = "3.9.2" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -groups = ["dev"] -files = [ - {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, - {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, -] - -[package.dependencies] -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.7.0,<2.8.0" -pyflakes = ">=2.3.0,<2.4.0" - [[package]] name = "frozenlist" -version = "1.5.0" +version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, - {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, - {file = "frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5"}, - {file = "frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb"}, - {file = "frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4"}, - {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30"}, - {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5"}, - {file = "frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf"}, - {file = "frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942"}, - {file = "frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d"}, - {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21"}, - {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d"}, - {file = "frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f"}, - {file = "frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8"}, - {file = "frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f"}, - {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953"}, - {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0"}, - {file = "frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03"}, - {file = "frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c"}, - {file = "frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28"}, - {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca"}, - {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10"}, - {file = "frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e"}, - {file = "frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723"}, - {file = "frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923"}, - {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972"}, - {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336"}, - {file = "frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c"}, - {file = "frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3"}, - {file = "frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0"}, - {file = "frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3"}, - {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] [[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" +name = "greenlet" +version = "3.5.6" +description = "Lightweight in-process concurrent programming" optional = false -python-versions = ">=3.6" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "greenlet-3.5.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:95e7c44d072db623a1aab04ce488cf9533294a77ed9d072cd503a3596f4106ac"}, + {file = "greenlet-3.5.6-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7d501d5eb5d4f67207df364752ad697465b834268744be7581c18d81d35d41d"}, + {file = "greenlet-3.5.6-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a364c1ea75dc51b83a17f52fe0c79cf8bc4ddf740403bebd4581c7666eea017d"}, + {file = "greenlet-3.5.6-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5599b380c1f28efeb724e81569eac80cd92f99a85bd9775456caaf3225d40b11"}, + {file = "greenlet-3.5.6-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eed88b64a5e5da72d6a71cdc5aaeefaa5ced9b748f8d19f89800b339961dad39"}, + {file = "greenlet-3.5.6-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:5bbda3c70dd35d60671bc33b01916802707a052130d9e50cdb871d34594d35cb"}, + {file = "greenlet-3.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:874cea8bb1ec1ddccbacbd027856f6bf496f6bc18aba97a918c20e067edab236"}, + {file = "greenlet-3.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:128813fc29f2336a21b4d06eedd5e16bcc7ea46f59e9ff1cb30ea70e48195d88"}, + {file = "greenlet-3.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:dad3d233d441a022c1f7155f0fb9d5aff7b97c1ea8c7dfa02cce586b16ab2d0b"}, + {file = "greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324"}, + {file = "greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa"}, + {file = "greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2"}, + {file = "greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b"}, + {file = "greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba"}, + {file = "greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586"}, + {file = "greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae"}, + {file = "greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13"}, + {file = "greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016"}, + {file = "greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32"}, + {file = "greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc"}, + {file = "greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44"}, + {file = "greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7"}, + {file = "greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395"}, + {file = "greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0"}, + {file = "greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519"}, + {file = "greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441"}, + {file = "greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815"}, + {file = "greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e"}, + {file = "greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a"}, + {file = "greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e"}, + {file = "greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e"}, + {file = "greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac"}, + {file = "greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d"}, + {file = "greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2"}, + {file = "greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46"}, + {file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb"}, + {file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b"}, + {file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b"}, + {file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88"}, + {file = "greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77"}, + {file = "greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02"}, + {file = "greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424"}, + {file = "greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a"}, + {file = "greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e"}, + {file = "greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951"}, + {file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49"}, + {file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b"}, + {file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d"}, + {file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc"}, + {file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81"}, + {file = "greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961"}, + {file = "greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404"}, + {file = "greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16"}, + {file = "greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3"}, + {file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6"}, + {file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0"}, + {file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4"}, + {file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605"}, + {file = "greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942"}, + {file = "greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c"}, + {file = "greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a"}, + {file = "greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756"}, + {file = "greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b"}, + {file = "greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78"}, + {file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a"}, + {file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877"}, + {file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577"}, + {file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec"}, + {file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7"}, + {file = "greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176"}, + {file = "greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf"}, + {file = "greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f"}, + {file = "greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24"}, + {file = "greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] [[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" +name = "idna" +version = "3.20" +description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"}, + {file = "idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"}, ] +[package.extras] +all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] + [[package]] -name = "iso8601" -version = "2.1.0" -description = "Simple module to parse ISO 8601 dates" -optional = false -python-versions = ">=3.7,<4.0" +name = "imageio-ffmpeg" +version = "0.6.0" +description = "FFMPEG wrapper for Python" +optional = true +python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"ffmpeg\"" files = [ - {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, - {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a"}, + {file = "imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755"}, ] [[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, - {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] -[package.extras] -colors = ["colorama (>=0.4.6)"] - [[package]] name = "m3u8" -version = "0.9.0" +version = "6.0.0" description = "Python m3u8 parser" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "m3u8-0.9.0-py3-none-any.whl", hash = "sha256:7dde0a20cf985422593810006dd371a1e3e7afd33a76277111eba3f220288902"}, - {file = "m3u8-0.9.0.tar.gz", hash = "sha256:3ee058855c430dc364db6b8026269d2b4c1894b198bcc5c824039c551c05f497"}, + {file = "m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d"}, + {file = "m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd"}, ] -[package.dependencies] -iso8601 = "*" - [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, + {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, + {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, ] [package.dependencies] @@ -738,25 +763,12 @@ mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] +plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mccabe" -version = "0.6.1" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] [[package]] name = "mdurl" @@ -772,602 +784,615 @@ files = [ [[package]] name = "multidict" -version = "6.1.0" +version = "6.9.1" description = "multidict implementation" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, - {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, - {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, - {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, - {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, - {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, - {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, - {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, - {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, - {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, - {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, - {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, - {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, - {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, - {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, + {file = "multidict-6.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8"}, + {file = "multidict-6.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19"}, + {file = "multidict-6.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c"}, + {file = "multidict-6.9.1-cp310-cp310-win32.whl", hash = "sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9"}, + {file = "multidict-6.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0"}, + {file = "multidict-6.9.1-cp310-cp310-win_arm64.whl", hash = "sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2"}, + {file = "multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4"}, + {file = "multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987"}, + {file = "multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13"}, + {file = "multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f"}, + {file = "multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e"}, + {file = "multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d"}, + {file = "multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe"}, + {file = "multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006"}, + {file = "multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6"}, + {file = "multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb"}, + {file = "multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203"}, + {file = "multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc"}, + {file = "multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3"}, + {file = "multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382"}, + {file = "multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a"}, + {file = "multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40"}, + {file = "multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8"}, + {file = "multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14"}, + {file = "multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40"}, + {file = "multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d"}, + {file = "multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4"}, + {file = "multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5"}, + {file = "multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9"}, + {file = "multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca"}, + {file = "multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840"}, + {file = "multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800"}, + {file = "multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194"}, + {file = "multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841"}, + {file = "multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3"}, + {file = "multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9"}, + {file = "multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76"}, + {file = "multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b"}, + {file = "multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e"}, + {file = "multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd"}, + {file = "multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9"}, + {file = "multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3"}, + {file = "multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1"}, + {file = "multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82"}, + {file = "multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea"}, + {file = "multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c"}, + {file = "multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08"}, + {file = "multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d"}, + {file = "multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d"}, + {file = "multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f"}, + {file = "multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f"}, + {file = "multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7"}, + {file = "multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68"}, + {file = "multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1"}, + {file = "multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1"}, + {file = "multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec"}, + {file = "multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3"}, + {file = "multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b"}, + {file = "multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} - [[package]] name = "mutagen" -version = "1.47.0" +version = "1.48.1" description = "read and write audio tags for many formats" optional = false -python-versions = ">=3.7" +python-versions = "<4,>=3.10" groups = ["main"] files = [ - {file = "mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719"}, - {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"}, -] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, + {file = "mutagen-1.48.1-py3-none-any.whl", hash = "sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7"}, + {file = "mutagen-1.48.1.tar.gz", hash = "sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173"}, ] [[package]] name = "packaging" -version = "24.2" +version = "26.3" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, ] [[package]] name = "pathvalidate" -version = "2.5.2" +version = "3.3.1" description = "pathvalidate is a Python library to sanitize/validate a string such as filenames/file-paths/etc." optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pathvalidate-2.5.2-py3-none-any.whl", hash = "sha256:e39a4dfacdba70e3a96d3e4c6ff617a39e991cf242e6e1f2017f1f67c3408d33"}, - {file = "pathvalidate-2.5.2.tar.gz", hash = "sha256:5ff57d0fabe5ecb7a4f1e4957bfeb5ad8ab5ab4c0fa71f79c6bbc24bd9b7d14d"}, + {file = "pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f"}, + {file = "pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177"}, ] [package.extras] -test = ["allpairspy", "click", "faker", "pytest (>=6.0.1)", "pytest-discord (>=0.0.6)", "pytest-md-report (>=0.0.12)"] +docs = ["Sphinx (>=2.4)", "sphinx_rtd_theme (>=1.2.2)", "urllib3 (<2)"] +readme = ["path (>=13,<18)", "readmemaker (>=1.2.0)"] +test = ["Faker (>=1.0.8)", "allpairspy (>=2)", "click (>=6.2)", "pytest (>=6.0.1)", "pytest-md-report (>=0.6.2)"] [[package]] name = "pick" -version = "2.4.0" +version = "2.6.0" description = "Pick an option in the terminal with a simple GUI" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"cygwin\"" +markers = "sys_platform == \"win32\"" files = [ - {file = "pick-2.4.0-py3-none-any.whl", hash = "sha256:2b07be18d16d655c7f491e1ecca7a29de3be85e1e000c8d46193672f14faa203"}, - {file = "pick-2.4.0.tar.gz", hash = "sha256:71f1b1b5d83652f87652fea5f51a3ba0b3388a71718cdcf8c6bc1326f85ae0b9"}, + {file = "pick-2.6.0-py3-none-any.whl", hash = "sha256:2acd5cad09be96398fba3b8be89454929de7b05362e01c69d94a05801820871f"}, + {file = "pick-2.6.0.tar.gz", hash = "sha256:f24434a845ae9265ebf7657fc224b9a203ac75aa576b568e061484a72e27be82"}, ] [package.dependencies] -windows-curses = {version = ">=2.2.0,<3.0.0", markers = "sys_platform == \"win32\""} +windows-curses = {version = ">=2.2.0", markers = "platform_system == \"Windows\""} + +[package.extras] +blessed = ["blessed (>=1.17.0)"] [[package]] name = "pillow" -version = "10.4.0" -description = "Python Imaging Library (Fork)" +version = "12.3.0" +description = "Python Imaging Library (fork)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, - {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, - {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, - {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, - {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, - {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, - {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, - {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, - {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, - {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, - {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, - {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, - {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, - {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, - {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, - {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, - {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, - {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, - {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, - {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, + {file = "pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a"}, + {file = "pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed"}, + {file = "pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1"}, + {file = "pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb"}, + {file = "pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5"}, + {file = "pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b"}, + {file = "pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a"}, + {file = "pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df"}, + {file = "pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f"}, + {file = "pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"}, + {file = "pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e"}, + {file = "pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f"}, + {file = "pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8"}, + {file = "pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130"}, + {file = "pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a"}, + {file = "pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d"}, + {file = "pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931"}, + {file = "pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7"}, + {file = "pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c"}, + {file = "pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71"}, + {file = "pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827"}, + {file = "pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5"}, + {file = "pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9"}, + {file = "pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8"}, + {file = "pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418"}, + {file = "pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a"}, + {file = "pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce"}, ] [package.extras] -docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions ; python_version < \"3.10\""] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] -name = "platformdirs" -version = "4.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +name = "playwright" +version = "1.63.0" +description = "A high-level API to automate web browsers" optional = false -python-versions = ">=3.8" -groups = ["dev"] +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, + {file = "playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d"}, + {file = "playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24"}, + {file = "playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f"}, + {file = "playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de"}, + {file = "playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7"}, + {file = "playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482"}, + {file = "playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6"}, + {file = "playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b"}, ] -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] +[package.dependencies] +greenlet = ">=3.1.1,<4.0.0" +pyee = ">=13,<14" [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "propcache" -version = "0.3.0" +version = "0.5.4" description = "Accelerated property cache" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa44f64c37cc30c9f05932c740a8b40ce359f51882c70883cc95feac842da4d"}, - {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2383a17385d9800b6eb5855c2f05ee550f803878f344f58b6e194de08b96352c"}, - {file = "propcache-0.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3e7420211f5a65a54675fd860ea04173cde60a7cc20ccfbafcccd155225f8bc"}, - {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3302c5287e504d23bb0e64d2a921d1eb4a03fb93a0a0aa3b53de059f5a5d737d"}, - {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2e068a83552ddf7a39a99488bcba05ac13454fb205c847674da0352602082f"}, - {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d913d36bdaf368637b4f88d554fb9cb9d53d6920b9c5563846555938d5450bf"}, - {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ee1983728964d6070ab443399c476de93d5d741f71e8f6e7880a065f878e0b9"}, - {file = "propcache-0.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36ca5e9a21822cc1746023e88f5c0af6fce3af3b85d4520efb1ce4221bed75cc"}, - {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9ecde3671e62eeb99e977f5221abcf40c208f69b5eb986b061ccec317c82ebd0"}, - {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d383bf5e045d7f9d239b38e6acadd7b7fdf6c0087259a84ae3475d18e9a2ae8b"}, - {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8cb625bcb5add899cb8ba7bf716ec1d3e8f7cdea9b0713fa99eadf73b6d4986f"}, - {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5fa159dcee5dba00c1def3231c249cf261185189205073bde13797e57dd7540a"}, - {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7080b0159ce05f179cfac592cda1a82898ca9cd097dacf8ea20ae33474fbb25"}, - {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed7161bccab7696a473fe7ddb619c1d75963732b37da4618ba12e60899fefe4f"}, - {file = "propcache-0.3.0-cp310-cp310-win32.whl", hash = "sha256:bf0d9a171908f32d54f651648c7290397b8792f4303821c42a74e7805bfb813c"}, - {file = "propcache-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:42924dc0c9d73e49908e35bbdec87adedd651ea24c53c29cac103ede0ea1d340"}, - {file = "propcache-0.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9ddd49258610499aab83b4f5b61b32e11fce873586282a0e972e5ab3bcadee51"}, - {file = "propcache-0.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2578541776769b500bada3f8a4eeaf944530516b6e90c089aa368266ed70c49e"}, - {file = "propcache-0.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8074c5dd61c8a3e915fa8fc04754fa55cfa5978200d2daa1e2d4294c1f136aa"}, - {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b58229a844931bca61b3a20efd2be2a2acb4ad1622fc026504309a6883686fbf"}, - {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e45377d5d6fefe1677da2a2c07b024a6dac782088e37c0b1efea4cfe2b1be19b"}, - {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5060592d83454e8063e487696ac3783cc48c9a329498bafae0d972bc7816c9"}, - {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15010f29fbed80e711db272909a074dc79858c6d28e2915704cfc487a8ac89c6"}, - {file = "propcache-0.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a254537b9b696ede293bfdbc0a65200e8e4507bc9f37831e2a0318a9b333c85c"}, - {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2b975528998de037dfbc10144b8aed9b8dd5a99ec547f14d1cb7c5665a43f075"}, - {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:19d36bb351ad5554ff20f2ae75f88ce205b0748c38b146c75628577020351e3c"}, - {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6032231d4a5abd67c7f71168fd64a47b6b451fbcb91c8397c2f7610e67683810"}, - {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6985a593417cdbc94c7f9c3403747335e450c1599da1647a5af76539672464d3"}, - {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a1948df1bb1d56b5e7b0553c0fa04fd0e320997ae99689488201f19fa90d2e7"}, - {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8319293e85feadbbfe2150a5659dbc2ebc4afdeaf7d98936fb9a2f2ba0d4c35c"}, - {file = "propcache-0.3.0-cp311-cp311-win32.whl", hash = "sha256:63f26258a163c34542c24808f03d734b338da66ba91f410a703e505c8485791d"}, - {file = "propcache-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:cacea77ef7a2195f04f9279297684955e3d1ae4241092ff0cfcef532bb7a1c32"}, - {file = "propcache-0.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e53d19c2bf7d0d1e6998a7e693c7e87300dd971808e6618964621ccd0e01fe4e"}, - {file = "propcache-0.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a61a68d630e812b67b5bf097ab84e2cd79b48c792857dc10ba8a223f5b06a2af"}, - {file = "propcache-0.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb91d20fa2d3b13deea98a690534697742029f4fb83673a3501ae6e3746508b5"}, - {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67054e47c01b7b349b94ed0840ccae075449503cf1fdd0a1fdd98ab5ddc2667b"}, - {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:997e7b8f173a391987df40f3b52c423e5850be6f6df0dcfb5376365440b56667"}, - {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d663fd71491dde7dfdfc899d13a067a94198e90695b4321084c6e450743b8c7"}, - {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8884ba1a0fe7210b775106b25850f5e5a9dc3c840d1ae9924ee6ea2eb3acbfe7"}, - {file = "propcache-0.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa806bbc13eac1ab6291ed21ecd2dd426063ca5417dd507e6be58de20e58dfcf"}, - {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f4d7a7c0aff92e8354cceca6fe223973ddf08401047920df0fcb24be2bd5138"}, - {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9be90eebc9842a93ef8335291f57b3b7488ac24f70df96a6034a13cb58e6ff86"}, - {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bf15fc0b45914d9d1b706f7c9c4f66f2b7b053e9517e40123e137e8ca8958b3d"}, - {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a16167118677d94bb48bfcd91e420088854eb0737b76ec374b91498fb77a70e"}, - {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:41de3da5458edd5678b0f6ff66691507f9885f5fe6a0fb99a5d10d10c0fd2d64"}, - {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:728af36011bb5d344c4fe4af79cfe186729efb649d2f8b395d1572fb088a996c"}, - {file = "propcache-0.3.0-cp312-cp312-win32.whl", hash = "sha256:6b5b7fd6ee7b54e01759f2044f936dcf7dea6e7585f35490f7ca0420fe723c0d"}, - {file = "propcache-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:2d15bc27163cd4df433e75f546b9ac31c1ba7b0b128bfb1b90df19082466ff57"}, - {file = "propcache-0.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a2b9bf8c79b660d0ca1ad95e587818c30ccdb11f787657458d6f26a1ea18c568"}, - {file = "propcache-0.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0c1a133d42c6fc1f5fbcf5c91331657a1ff822e87989bf4a6e2e39b818d0ee9"}, - {file = "propcache-0.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bb2f144c6d98bb5cbc94adeb0447cfd4c0f991341baa68eee3f3b0c9c0e83767"}, - {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1323cd04d6e92150bcc79d0174ce347ed4b349d748b9358fd2e497b121e03c8"}, - {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b812b3cb6caacd072276ac0492d249f210006c57726b6484a1e1805b3cfeea0"}, - {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:742840d1d0438eb7ea4280f3347598f507a199a35a08294afdcc560c3739989d"}, - {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c6e7e4f9167fddc438cd653d826f2222222564daed4116a02a184b464d3ef05"}, - {file = "propcache-0.3.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a94ffc66738da99232ddffcf7910e0f69e2bbe3a0802e54426dbf0714e1c2ffe"}, - {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c6ec957025bf32b15cbc6b67afe233c65b30005e4c55fe5768e4bb518d712f1"}, - {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:549722908de62aa0b47a78b90531c022fa6e139f9166be634f667ff45632cc92"}, - {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5d62c4f6706bff5d8a52fd51fec6069bef69e7202ed481486c0bc3874912c787"}, - {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:24c04f8fbf60094c531667b8207acbae54146661657a1b1be6d3ca7773b7a545"}, - {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7c5f5290799a3f6539cc5e6f474c3e5c5fbeba74a5e1e5be75587746a940d51e"}, - {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0e7c9c3cf7c276d4f6ab9af8adddc127d04e0fcabede315904d2ff76db626"}, - {file = "propcache-0.3.0-cp313-cp313-win32.whl", hash = "sha256:ee0bd3a7b2e184e88d25c9baa6a9dc609ba25b76daae942edfb14499ac7ec374"}, - {file = "propcache-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c8f7d896a16da9455f882870a507567d4f58c53504dc2d4b1e1d386dfe4588a"}, - {file = "propcache-0.3.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e560fd75aaf3e5693b91bcaddd8b314f4d57e99aef8a6c6dc692f935cc1e6bbf"}, - {file = "propcache-0.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65a37714b8ad9aba5780325228598a5b16c47ba0f8aeb3dc0514701e4413d7c0"}, - {file = "propcache-0.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:07700939b2cbd67bfb3b76a12e1412405d71019df00ca5697ce75e5ef789d829"}, - {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c0fdbdf6983526e269e5a8d53b7ae3622dd6998468821d660d0daf72779aefa"}, - {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:794c3dd744fad478b6232289c866c25406ecdfc47e294618bdf1697e69bd64a6"}, - {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4544699674faf66fb6b4473a1518ae4999c1b614f0b8297b1cef96bac25381db"}, - {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fddb8870bdb83456a489ab67c6b3040a8d5a55069aa6f72f9d872235fbc52f54"}, - {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f857034dc68d5ceb30fb60afb6ff2103087aea10a01b613985610e007053a121"}, - {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02df07041e0820cacc8f739510078f2aadcfd3fc57eaeeb16d5ded85c872c89e"}, - {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f47d52fd9b2ac418c4890aad2f6d21a6b96183c98021f0a48497a904199f006e"}, - {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9ff4e9ecb6e4b363430edf2c6e50173a63e0820e549918adef70515f87ced19a"}, - {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ecc2920630283e0783c22e2ac94427f8cca29a04cfdf331467d4f661f4072dac"}, - {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c441c841e82c5ba7a85ad25986014be8d7849c3cfbdb6004541873505929a74e"}, - {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c929916cbdb540d3407c66f19f73387f43e7c12fa318a66f64ac99da601bcdf"}, - {file = "propcache-0.3.0-cp313-cp313t-win32.whl", hash = "sha256:0c3e893c4464ebd751b44ae76c12c5f5c1e4f6cbd6fbf67e3783cd93ad221863"}, - {file = "propcache-0.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:75e872573220d1ee2305b35c9813626e620768248425f58798413e9c39741f46"}, - {file = "propcache-0.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:03c091bb752349402f23ee43bb2bff6bd80ccab7c9df6b88ad4322258d6960fc"}, - {file = "propcache-0.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46ed02532cb66612d42ae5c3929b5e98ae330ea0f3900bc66ec5f4862069519b"}, - {file = "propcache-0.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11ae6a8a01b8a4dc79093b5d3ca2c8a4436f5ee251a9840d7790dccbd96cb649"}, - {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df03cd88f95b1b99052b52b1bb92173229d7a674df0ab06d2b25765ee8404bce"}, - {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03acd9ff19021bd0567582ac88f821b66883e158274183b9e5586f678984f8fe"}, - {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd54895e4ae7d32f1e3dd91261df46ee7483a735017dc6f987904f194aa5fd14"}, - {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a67e5c04e3119594d8cfae517f4b9330c395df07ea65eab16f3d559b7068fe"}, - {file = "propcache-0.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee25f1ac091def37c4b59d192bbe3a206298feeb89132a470325bf76ad122a1e"}, - {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58e6d2a5a7cb3e5f166fd58e71e9a4ff504be9dc61b88167e75f835da5764d07"}, - {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:be90c94570840939fecedf99fa72839aed70b0ced449b415c85e01ae67422c90"}, - {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49ea05212a529c2caffe411e25a59308b07d6e10bf2505d77da72891f9a05641"}, - {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:119e244ab40f70a98c91906d4c1f4c5f2e68bd0b14e7ab0a06922038fae8a20f"}, - {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:507c5357a8d8b4593b97fb669c50598f4e6cccbbf77e22fa9598aba78292b4d7"}, - {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8526b0941ec5a40220fc4dfde76aed58808e2b309c03e9fa8e2260083ef7157f"}, - {file = "propcache-0.3.0-cp39-cp39-win32.whl", hash = "sha256:7cedd25e5f678f7738da38037435b340694ab34d424938041aa630d8bac42663"}, - {file = "propcache-0.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:bf4298f366ca7e1ad1d21bbb58300a6985015909964077afd37559084590c929"}, - {file = "propcache-0.3.0-py3-none-any.whl", hash = "sha256:67dda3c7325691c2081510e92c561f465ba61b975f481735aefdfc845d2cd043"}, - {file = "propcache-0.3.0.tar.gz", hash = "sha256:a8fd93de4e1d278046345f49e2238cdb298589325849b2645d4a94c53faeffc5"}, + {file = "propcache-0.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b77c313314524ca9c38fbd70f73515d04597ac58c40c939bc0e71eeb4abff680"}, + {file = "propcache-0.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8f911c395cef73c510bac566da9507bb6a43e7763d0c79138dc60ee53f11207e"}, + {file = "propcache-0.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d83b12902eb8bce151259c86c03ba746600b2d994543de46e370cecf96c452f2"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9281e922c072158c91974d4589f1dbe0fee6d467f284c28e463f9f5a4d933f4"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f3551b8a35c1df3e7ea4d2d86edee15f0dde1bddd434a71744048683544d0ef"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ec6a85f424afa8d23e0d9a094e5dbb6eda01da91c92b9183cd433768247ffc97"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f574e460d1c8a08384a016fdb09ccf3543433263ed6b2f97104f979e64ea57c2"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8e017eeb7482bed34cdb0d61cf2bcfc88d104bbab296a17cd16a6af8aabc70e"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f273dcf7149a50527c4fd1f55cfe9eac0f60753f5af544b4c9352578e20c0874"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:fc2461ecc45f17893f8207e73b46ea8ba93e33630e51cf4af3fbc21d47462b1a"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:279655a16973f1ee2bd2fe79973137681642fd9ae0d89215bba263726eb0dc3a"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e9f165403b81fea7e89c932d89046a1e3d9a3a60e8d7ef2f249dccdcb0982bf5"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1783582065a1f07f9d9ee1e992e13f15d7dc8fb1eb3a7476d43eb3f2e69d26bb"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d605bb239b796e82a81c6709548b2bd460ab73b4590cb0c83de8a2dd9694d0f"}, + {file = "propcache-0.5.4-cp310-cp310-win32.whl", hash = "sha256:141fdbd73748db0cf7636035030aaac383d2efde8f34e7bc24594cc776d225b8"}, + {file = "propcache-0.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:146f48a9e4812611a7581003b1a39de56c34967046310c4171a68ef908c9a745"}, + {file = "propcache-0.5.4-cp310-cp310-win_arm64.whl", hash = "sha256:6c7599df2b57ebeea8de011b5f2f7b85de95e76037d43d34b95e328430275487"}, + {file = "propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e"}, + {file = "propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d"}, + {file = "propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd"}, + {file = "propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9"}, + {file = "propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858"}, + {file = "propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161"}, + {file = "propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8"}, + {file = "propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb"}, + {file = "propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737"}, + {file = "propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba"}, + {file = "propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0"}, + {file = "propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c"}, + {file = "propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25"}, + {file = "propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77"}, + {file = "propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177"}, + {file = "propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565"}, + {file = "propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993"}, + {file = "propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b"}, + {file = "propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea"}, + {file = "propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983"}, + {file = "propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac"}, + {file = "propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0"}, + {file = "propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5"}, + {file = "propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57"}, + {file = "propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97"}, + {file = "propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12"}, + {file = "propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827"}, + {file = "propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760"}, + {file = "propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51"}, + {file = "propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38"}, + {file = "propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123"}, + {file = "propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3"}, + {file = "propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30"}, + {file = "propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f"}, + {file = "propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3"}, + {file = "propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad"}, + {file = "propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec"}, + {file = "propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432"}, + {file = "propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e"}, + {file = "propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468"}, + {file = "propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558"}, ] [[package]] -name = "pycares" -version = "4.5.0" -description = "Python interface for c-ares" +name = "pycryptodomex" +version = "3.23.0" +description = "Cryptographic library for Python" optional = false -python-versions = ">=3.9" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] files = [ - {file = "pycares-4.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:13a82fad8239d6fbcf916099bee17d8b5666d0ddb77dace431e0f7961c9427ab"}, - {file = "pycares-4.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fefc7bebbe39b2e3b4b9615471233a8f7356b96129a7db9030313a3ae4ecc42d"}, - {file = "pycares-4.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e322e8ce810026f6e0c7c2a254b9ed02191ab8d42fa2ce6808ede1bdccab8e65"}, - {file = "pycares-4.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:723ba0803b016294430e40e544503fed9164949b694342c2552ab189e2b688ef"}, - {file = "pycares-4.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e48b20b59cdc929cc712a8b22e89c273256e482b49bb8999af98d2c6fc4563c2"}, - {file = "pycares-4.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de6e55bd9af595b112ac6080ac0a0d52b5853d0d8e6d01ac65ff09e51e62490a"}, - {file = "pycares-4.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6f4b9063e3dd70460400367917698f209c10aabb68bf70b09e364895444487d"}, - {file = "pycares-4.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:95522d4840d702fd766439a7c7cd747935aa54cf0b8675e9fadd8414dd9dd0df"}, - {file = "pycares-4.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4709ce4fd9dbee24b1397f71a2adb3267323bb5ad5e7fde3f87873d172dd156"}, - {file = "pycares-4.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8addbf3408af1010f50fd67ef634a6cb239ccb9c534c32a40713f3b8d306a98e"}, - {file = "pycares-4.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d0428ef42fcf575e197047e6a47892404faa34231902a453b3dfed66af4178b3"}, - {file = "pycares-4.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:aed5c2732f3a6bdbbfab202267d37044ca1162f690b9d34b7ece97ba43f27453"}, - {file = "pycares-4.5.0-cp310-cp310-win32.whl", hash = "sha256:b1859ea770a7abec40a6d02b5ab03c2396c4900c01f4e50ddb6c0dca4c2a6a7c"}, - {file = "pycares-4.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9f87d8da20a3a80ab05fe80c14a62bf078bd726ca6af609edbeb376fb97d50ab"}, - {file = "pycares-4.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ca7a1dba7b88290710db45012e0903c21c839fa0a2b9ddc100bba8e66bfb251"}, - {file = "pycares-4.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:160e92588cdf1a0fa3a7015f47990b508d50efd9109ea4d719dee31c058f0648"}, - {file = "pycares-4.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f38e45d23660ed1dafdb956fd263ae4735530ef1578aa2bf2caabb94cee4523"}, - {file = "pycares-4.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f742acc6d29a99ffc14e3f154b3848ea05c5533b71065e0f0a0fd99c527491b2"}, - {file = "pycares-4.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ceaf71bcd7b6447705e689b8fee8836c20c6148511a90122981f524a84bfcca9"}, - {file = "pycares-4.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdc3c0be7b5b83e78e28818fecd0405bd401110dd6e2e66f7f10713c1188362c"}, - {file = "pycares-4.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd458ee69800195247aa19b5675c5914cbc091c5a220e4f0e96777a31bb555c1"}, - {file = "pycares-4.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6649d713df73266708642fc3d04f110c0a66bee510fbce4cc5fed79df42083"}, - {file = "pycares-4.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ac57d7bda925c10b997434e7ce30a2c3689c2e96bab9fd0a1165d5577378eecd"}, - {file = "pycares-4.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba17d8e5eeec4b2e0eb1a6a840bae9e62cd1c1c9cbc8dc9db9d1b9fdf33d0b54"}, - {file = "pycares-4.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9e9b7d1a8de703283e4735c0e532ba4bc600e88de872dcd1a9a4950cf74d9f4f"}, - {file = "pycares-4.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c6922ecbe458c13a4a2c1177bbce38abc44b5f086bc82115a92eab34418915f"}, - {file = "pycares-4.5.0-cp311-cp311-win32.whl", hash = "sha256:1004b8a17614e33410b4b1bb68360977667f1cc9ab2dbcfb27240d6703e4cb6a"}, - {file = "pycares-4.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:2c9c1055c622258a0f315560b2880a372363484b87cbef48af092624804caa72"}, - {file = "pycares-4.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:506efbe5017807747ccd1bdcb3c2f6e64635bc01fee01a50c0b97d649018c162"}, - {file = "pycares-4.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c469ec9fbe0526f45a98f67c1ea55be03abf30809c4f9c9be4bc93fb6806304d"}, - {file = "pycares-4.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597c0950ede240c3a779f023fcf2442207fc11e570d3ca4ccdbb0db5bbaf2588"}, - {file = "pycares-4.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9aa0da03c4df6ed0f87dd52a293bd0508734515041cc5be0f85d9edc1814914f"}, - {file = "pycares-4.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1ebf52767c777d10a1b3d03844b9b05cc892714b3ee177d5d9fbff74fb9fa"}, - {file = "pycares-4.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb20d84269ddffb177b6048e3bc03d0b9ffe17592093d900d5544805958d86b3"}, - {file = "pycares-4.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3125df81b657971ee5c0333f8f560ba0151db1eb7cf04aea7d783bb433b306c1"}, - {file = "pycares-4.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:525c77ea44546c12f379641aee163585d403cf50e29b04a06059d6aac894e956"}, - {file = "pycares-4.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1fd87cb26b317a9988abfcfa4e4dbc55d5f20177e5979ad4d854468a9246c187"}, - {file = "pycares-4.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a90aecd41188884e57ae32507a2c6b010c60b791a253083761bbb37a488ecaed"}, - {file = "pycares-4.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0d3de65cab653979dcc491e03f596566c9d40346c9deb088e0f9fe70600d8737"}, - {file = "pycares-4.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27a77b43604b3ba24e4fc49fd3ea59f50f7d89c7255f1f1ea46928b26cccacfa"}, - {file = "pycares-4.5.0-cp312-cp312-win32.whl", hash = "sha256:6028cb8766f0fea1d2caa69fac23621fbe2cff9ce6968374e165737258703a33"}, - {file = "pycares-4.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:2ce10672c4cfd1c5fb6718e8b25f0336ca11c89aab88aa6df53dafc4e41df740"}, - {file = "pycares-4.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:011cd670da7caf55664c944abb71ec39af82b837f8d48da7cf0eec80f5682c4c"}, - {file = "pycares-4.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b5c67930497fb2b1dbcaa85f8c4188fc2cb62e41d787deeed2d33cfe9dd6bf52"}, - {file = "pycares-4.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d435a3b8468c656a7e7180dd7c4794510f6c612c33ad61a0fff6e440621f8b5"}, - {file = "pycares-4.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8371f5ee1efb33d6276e275d152c9c5605e5f2e58a9e168519ec1f9e13dd95ae"}, - {file = "pycares-4.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76a9096fd5dc49c61c5235ea7032e8b43f4382800d64ca1e0e0cda700c082aa"}, - {file = "pycares-4.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b604af76b57469ff68b44e9e4c857eaee43bc5035f4f183f07f4f7149191fe1b"}, - {file = "pycares-4.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c589bd4f9160bfdb2f8080cf564bb120a4312cf091db07fe417f8e58a896a63c"}, - {file = "pycares-4.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:361262805bb09742c364ec0117842043c950339e38561009bcabbb6ac89458ef"}, - {file = "pycares-4.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d2afb3c0776467055bf33db843ef483d25639be0f32e3a13ef5d4dc64098bf5"}, - {file = "pycares-4.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bc7a1d8ed7c7a4de17706a3c89b305b02eb64c778897e6727c043e5b9dd0d853"}, - {file = "pycares-4.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5703ec878b5c1efacdbf24ceaedfa606112fc67af5564f4db99c2c210f3ffadc"}, - {file = "pycares-4.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d87758e09dbf52c27ed7cf7bc7eaf8b3226217d10c52b03d61a14d59f40fcae1"}, - {file = "pycares-4.5.0-cp313-cp313-win32.whl", hash = "sha256:3316d490b4ce1a69f034881ac1ea7608f5f24ea5293db24ab574ac70b7d7e407"}, - {file = "pycares-4.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:018e700fb0d1a2db5ec96e404ffa85ed97cc96e96d6af0bb9548111e37cf36a3"}, - {file = "pycares-4.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:78c9890d93108c70708babee8a783e6021233f1f0a763d3634add6fd429aae58"}, - {file = "pycares-4.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba69f8123995aa3df99f6ebc726fc6a4b08e467a957b215c0a82749b901d5eed"}, - {file = "pycares-4.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d33c4ffae31d1b544adebe0b9aee2be1fb18aedd3f4f91e41c495ccbafd6d8"}, - {file = "pycares-4.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17a060cfc469828abf7f5945964d505bd8c0a756942fee159538f7885169752e"}, - {file = "pycares-4.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1d0d5e69fa29e41b590a9dd5842454e8f34e2b928c92540aaf87e0161de8120"}, - {file = "pycares-4.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f096699c46f5dde2c7a8d91501a36d2d58500f4d63682e2ec14a0fed7cca6402"}, - {file = "pycares-4.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:429fe2065581a64a5f024f507b5f679bf37ea0ed39c3ba6289dba907e1c8a8f4"}, - {file = "pycares-4.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ea2f6d48e64b413b97b41b47392087b452af9bf9f9d4d6d05305a159f45909f"}, - {file = "pycares-4.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:96d3aecd747a3fcd1e12c1ea1481b0813b4e0e80d40f314db7a86dda5bb1bd94"}, - {file = "pycares-4.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:32919f6eda7f5ea4df3e64149fc5792b0d455277d23d6d0fc365142062f35d80"}, - {file = "pycares-4.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:37add862461f9a3fc7ee4dd8b68465812b39456e21cebd5a33c414131ac05060"}, - {file = "pycares-4.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ed1d050d2c6d74a77c1b6c51fd99426cc000b4202a50d28d6ca75f7433099a6b"}, - {file = "pycares-4.5.0-cp39-cp39-win32.whl", hash = "sha256:887ac451ffe6e39ee46d3d0989c7bb829933d77e1dad5776511d825fc7e6a25b"}, - {file = "pycares-4.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c8b87c05740595bc8051dc98e51f022f003750e7da90f62f7a9fd50e330b196"}, - {file = "pycares-4.5.0.tar.gz", hash = "sha256:025b6c2ffea4e9fb8f9a097381c2fecb24aff23fbd6906e70da22ec9ba60e19d"}, -] - -[package.dependencies] -cffi = ">=1.5.0" - -[package.extras] -idna = ["idna (>=2.1)"] - -[[package]] -name = "pycodestyle" -version = "2.7.0" -description = "Python style guide checker" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, - {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, + {file = "pycryptodomex-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:add243d204e125f189819db65eed55e6b4713f70a7e9576c043178656529cec7"}, + {file = "pycryptodomex-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1c6d919fc8429e5cb228ba8c0d4d03d202a560b421c14867a65f6042990adc8e"}, + {file = "pycryptodomex-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:1c3a65ad441746b250d781910d26b7ed0a396733c6f2dbc3327bd7051ec8a541"}, + {file = "pycryptodomex-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:47f6d318fe864d02d5e59a20a18834819596c4ed1d3c917801b22b92b3ffa648"}, + {file = "pycryptodomex-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:d9825410197a97685d6a1fa2a86196430b01877d64458a20e95d4fd00d739a08"}, + {file = "pycryptodomex-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:267a3038f87a8565bd834317dbf053a02055915acf353bf42ededb9edaf72010"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708"}, + {file = "pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9"}, + {file = "pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51"}, + {file = "pycryptodomex-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:febec69c0291efd056c65691b6d9a339f8b4bc43c6635b8699471248fe897fea"}, + {file = "pycryptodomex-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:c84b239a1f4ec62e9c789aafe0543f0594f0acd90c8d9e15bcece3efe55eca66"}, + {file = "pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5"}, + {file = "pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798"}, + {file = "pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f"}, + {file = "pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea"}, + {file = "pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe"}, + {file = "pycryptodomex-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7de1e40a41a5d7f1ac42b6569b10bcdded34339950945948529067d8426d2785"}, + {file = "pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bffc92138d75664b6d543984db7893a628559b9e78658563b0395e2a5fb47ed9"}, + {file = "pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df027262368334552db2c0ce39706b3fb32022d1dce34673d0f9422df004b96a"}, + {file = "pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e79f1aaff5a3a374e92eb462fa9e598585452135012e2945f96874ca6eeb1ff"}, + {file = "pycryptodomex-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:27e13c80ac9a0a1d050ef0a7e0a18cc04c8850101ec891815b6c5a0375e8a245"}, + {file = "pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da"}, ] [[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" +name = "pyee" +version = "13.0.1" +description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, + {file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"}, + {file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"}, ] -[[package]] -name = "pycryptodomex" -version = "3.21.0" -description = "Cryptographic library for Python" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -groups = ["main"] -files = [ - {file = "pycryptodomex-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822"}, - {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd"}, - {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:1233443f19d278c72c4daae749872a4af3787a813e05c3561c73ab0c153c7b0f"}, - {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbb07f88e277162b8bfca7134b34f18b400d84eac7375ce73117f865e3c80d4c"}, - {file = "pycryptodomex-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:e859e53d983b7fe18cb8f1b0e29d991a5c93be2c8dd25db7db1fe3bd3617f6f9"}, - {file = "pycryptodomex-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:ef046b2e6c425647971b51424f0f88d8a2e0a2a63d3531817968c42078895c00"}, - {file = "pycryptodomex-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:da76ebf6650323eae7236b54b1b1f0e57c16483be6e3c1ebf901d4ada47563b6"}, - {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c07e64867a54f7e93186a55bec08a18b7302e7bee1b02fd84c6089ec215e723a"}, - {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:56435c7124dd0ce0c8bdd99c52e5d183a0ca7fdcd06c5d5509423843f487dd0b"}, - {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65d275e3f866cf6fe891411be9c1454fb58809ccc5de6d3770654c47197acd65"}, - {file = "pycryptodomex-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:5241bdb53bcf32a9568770a6584774b1b8109342bd033398e4ff2da052123832"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:34325b84c8b380675fd2320d0649cdcbc9cf1e0d1526edbe8fce43ed858cdc7e"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:103c133d6cd832ae7266feb0a65b69e3a5e4dbbd6f3a3ae3211a557fd653f516"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77ac2ea80bcb4b4e1c6a596734c775a1615d23e31794967416afc14852a639d3"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9aa0cf13a1a1128b3e964dc667e5fe5c6235f7d7cfb0277213f0e2a783837cc2"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46eb1f0c8d309da63a2064c28de54e5e614ad17b7e2f88df0faef58ce192fc7b"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cc7e111e66c274b0df5f4efa679eb31e23c7545d702333dfd2df10ab02c2a2ce"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:770d630a5c46605ec83393feaa73a9635a60e55b112e1fb0c3cea84c2897aa0a"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:52e23a0a6e61691134aa8c8beba89de420602541afaae70f66e16060fdcd677e"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-win32.whl", hash = "sha256:a3d77919e6ff56d89aada1bd009b727b874d464cb0e2e3f00a49f7d2e709d76e"}, - {file = "pycryptodomex-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b0e9765f93fe4890f39875e6c90c96cb341767833cfa767f41b490b506fa9ec0"}, - {file = "pycryptodomex-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:feaecdce4e5c0045e7a287de0c4351284391fe170729aa9182f6bd967631b3a8"}, - {file = "pycryptodomex-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:365aa5a66d52fd1f9e0530ea97f392c48c409c2f01ff8b9a39c73ed6f527d36c"}, - {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3efddfc50ac0ca143364042324046800c126a1d63816d532f2e19e6f2d8c0c31"}, - {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df2608682db8279a9ebbaf05a72f62a321433522ed0e499bc486a6889b96bf3"}, - {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5823d03e904ea3e53aebd6799d6b8ec63b7675b5d2f4a4bd5e3adcb512d03b37"}, - {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:27e84eeff24250ffec32722334749ac2a57a5fd60332cd6a0680090e7c42877e"}, - {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ef436cdeea794015263853311f84c1ff0341b98fc7908e8a70595a68cefd971"}, - {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1058e6dfe827f4209c5cae466e67610bcd0d66f2f037465daa2a29d92d952b"}, - {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba09a5b407cbb3bcb325221e346a140605714b5e880741dc9a1e9ecf1688d42"}, - {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8a9d8342cf22b74a746e3c6c9453cb0cfbb55943410e3a2619bd9164b48dc9d9"}, - {file = "pycryptodomex-3.21.0.tar.gz", hash = "sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c"}, -] +[package.dependencies] +typing-extensions = "*" -[[package]] -name = "pyflakes" -version = "2.3.1" -description = "passive checker of Python programs" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["dev"] -files = [ - {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, - {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, -] +[package.extras] +dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] [[package]] name = "pygments" -version = "2.19.1" +version = "2.21.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, + {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, + {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, ] [package.extras] @@ -1375,56 +1400,55 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "7.4.4" +version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.21.2" +version = "1.4.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.7" -groups = ["main"] +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b"}, - {file = "pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45"}, + {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, + {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, ] [package.dependencies] -pytest = ">=7.0.0" +pytest = ">=8.4,<10" [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-mock" -version = "3.14.0" +version = "3.16.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, + {file = "pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8"}, + {file = "pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636"}, ] [package.dependencies] @@ -1435,90 +1459,73 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "requests" -version = "2.32.3" +version = "2.34.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rich" -version = "13.9.4" +version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.9.0" groups = ["main"] files = [ - {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, - {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.1.15" +version = "0.16.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, - {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, - {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, - {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, - {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, - {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, - {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, - {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, -] - -[[package]] -name = "setuptools" -version = "67.8.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "setuptools-67.8.0-py3-none-any.whl", hash = "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f"}, - {file = "setuptools-67.8.0.tar.gz", hash = "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102"}, + {file = "ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96"}, + {file = "ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67"}, + {file = "ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588"}, + {file = "ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385"}, + {file = "ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b"}, + {file = "ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284"}, + {file = "ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161"}, ] -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov ; platform_python_implementation != \"PyPy\"", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf", "pytest-ruff ; sys_platform != \"cygwin\"", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "simple-term-menu" version = "1.6.6" @@ -1526,114 +1533,46 @@ description = "A Python package which creates simple interactive menus on the co optional = false python-versions = "~=3.5" groups = ["main"] -markers = "sys_platform == \"darwin\" or sys_platform == \"linux\"" +markers = "sys_platform != \"win32\"" files = [ {file = "simple_term_menu-1.6.6-py3-none-any.whl", hash = "sha256:c2a869efa7a9f7e4a9c25858b42ca6974034951c137d5e281f5339b06ed8c9c2"}, {file = "simple_term_menu-1.6.6.tar.gz", hash = "sha256:9813d36f5749d62d200a5599b1ec88469c71378312adc084c00c00bfbb383893"}, ] -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - [[package]] name = "tomlkit" -version = "0.7.2" +version = "0.15.1" description = "Style preserving TOML library" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "tomlkit-0.7.2-py2.py3-none-any.whl", hash = "sha256:173ad840fa5d2aac140528ca1933c29791b79a374a0861a80347f42ec9328117"}, - {file = "tomlkit-0.7.2.tar.gz", hash = "sha256:d7a454f319a7e9bd2e249f239168729327e4dd2d27b17dc68be264ad1ce36754"}, -] - -[[package]] -name = "types-click" -version = "7.1.8" -description = "Typing stubs for click" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "types-click-7.1.8.tar.gz", hash = "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"}, - {file = "types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81"}, -] - -[[package]] -name = "types-pillow" -version = "8.3.11" -description = "Typing stubs for Pillow" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "types-Pillow-8.3.11.tar.gz", hash = "sha256:aa96a739184f48f69e6f30218400623fc5a95f5fec199c447663a32538440405"}, - {file = "types_Pillow-8.3.11-py3-none-any.whl", hash = "sha256:998189334e616b1dd42c9634669efbf726184039e96e9a23ec95246e0ecff3fc"}, + {file = "tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"}, + {file = "tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"}, ] [[package]] name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] name = "urllib3" -version = "2.6.3" +version = "2.8.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, ] [package.extras] @@ -1644,132 +1583,191 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "windows-curses" -version = "2.4.1" +version = "2.4.2" description = "Support for the standard curses module on Windows" optional = false python-versions = "*" groups = ["main"] -markers = "sys_platform == \"win32\" or sys_platform == \"cygwin\"" +markers = "sys_platform == \"win32\"" files = [ - {file = "windows_curses-2.4.1-cp310-cp310-win32.whl", hash = "sha256:53d711e07194d0d3ff7ceff29e0955b35479bc01465d46c3041de67b8141db2f"}, - {file = "windows_curses-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:325439cd4f37897a1de8a9c068a5b4c432f9244bf9c855ee2fbeb3fa721a770c"}, - {file = "windows_curses-2.4.1-cp311-cp311-win32.whl", hash = "sha256:4fa1a176bfcf098d0c9bb7bc03dce6e83a4257fc0c66ad721f5745ebf0c00746"}, - {file = "windows_curses-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fd7d7a9cf6c1758f46ed76b8c67f608bc5fcd5f0ca91f1580fd2d84cf41c7f4f"}, - {file = "windows_curses-2.4.1-cp312-cp312-win32.whl", hash = "sha256:bdbe7d58747408aef8a9128b2654acf6fbd11c821b91224b9a046faba8c6b6ca"}, - {file = "windows_curses-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c9c2635faf171a229caca80e1dd760ab00db078e2a285ba2f667bbfcc31777c"}, - {file = "windows_curses-2.4.1-cp313-cp313-win32.whl", hash = "sha256:05d1ca01e5199a435ccb6c8c2978df4a169cdff1ec99ab15f11ded9de8e5be26"}, - {file = "windows_curses-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8cf653f8928af19c103ae11cfed38124f418dcdd92643c4cd17239c0cec2f9da"}, - {file = "windows_curses-2.4.1-cp36-cp36m-win32.whl", hash = "sha256:6a5a831cabaadde41a6856fea5a0c68c74b7d11d332a816e5a5e6c84577aef3a"}, - {file = "windows_curses-2.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e61be805edc390ccfdeaf0e0c39736d931d3c4a007d6bf0f98d1e792ce437796"}, - {file = "windows_curses-2.4.1-cp37-cp37m-win32.whl", hash = "sha256:a36b8fd4e410ddfb1a8eb65af2116c588e9f99b2ff3404412317440106755485"}, - {file = "windows_curses-2.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:db776df70c10bd523c4a1ab0a7624a1d58c7d47f83ec49c6988f05bc1189e7b8"}, - {file = "windows_curses-2.4.1-cp38-cp38-win32.whl", hash = "sha256:e9ce84559f80de7ec770d28c3b2991e0da51748def04e25a3c08ada727cfac2d"}, - {file = "windows_curses-2.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:618e31458fedba2cf8105485ff00533ece780026c544142fc1647a20dc6c7641"}, - {file = "windows_curses-2.4.1-cp39-cp39-win32.whl", hash = "sha256:775a2e0fefeddfdb0e00b3fa6c4f21caf9982db34df30e4e62c49caaef7b5e56"}, - {file = "windows_curses-2.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:4588213f7ef3b0c24c5cb9e309653d7a84c1792c707561e8b471d466ca79f2b8"}, + {file = "windows_curses-2.4.2-cp310-cp310-win32.whl", hash = "sha256:fb55b40df1dd8633f431307f204293ca2a0b4f928927490509f1bf68beef3cfe"}, + {file = "windows_curses-2.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ff2a0884cb78d48e15eea1e274f73c1f5a780a4b872357724c17b39de75c996"}, + {file = "windows_curses-2.4.2-cp311-cp311-win32.whl", hash = "sha256:ecf7af9c97002af661d3e47b9c5eb31cdb7629a032adcdab46530efea8ba1864"}, + {file = "windows_curses-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:03c4ef0095ab8212ed964236d51b4d8c815f302c5a7de94831dae468f05ce333"}, + {file = "windows_curses-2.4.2-cp312-cp312-win32.whl", hash = "sha256:7ec4629685add716d494e8fbdd0b83485de726e9deeda57d30e742c77a32a156"}, + {file = "windows_curses-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:df782859176f9e8142618e4966d6cee7a53a8b1c819faeb6e606a60fa4f8972a"}, + {file = "windows_curses-2.4.2-cp313-cp313-win32.whl", hash = "sha256:248bf3f1d973e92ca193b1ea8a626ec7f81a66be6f6d6c5e31ff82056453678d"}, + {file = "windows_curses-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:1bf3d0c902374b6c430a954adcb686a5ecd7a31fc4195d31136385b5c1bea925"}, + {file = "windows_curses-2.4.2-cp314-cp314-win32.whl", hash = "sha256:a970bf067b3126251423b4da051ef8c1c77acf6294ec96a7d6de7f6b9e5c27a0"}, + {file = "windows_curses-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:6c22cb0310500dfbfe54c76354e37070e300d5473596d48ac4f9c65b2d578b6f"}, + {file = "windows_curses-2.4.2-cp36-cp36m-win32.whl", hash = "sha256:aa1b97c94882ac0c0019a0dcbb9b57a3f751e166e0ed6d99230e5d77bfb7f2b5"}, + {file = "windows_curses-2.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f03193397e20e9f86d5a661c63c01d305d63ffcefc2bca46e1881f2c06af2a38"}, + {file = "windows_curses-2.4.2-cp37-cp37m-win32.whl", hash = "sha256:6ff56a1cc6410cb7600e5af7c01178d534ca704f9bc91da433719fccbe7c26c6"}, + {file = "windows_curses-2.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:cc890992a9a554b9e39354db672bd2b74110dd5ed7df0386a9c2d2956e964130"}, + {file = "windows_curses-2.4.2-cp38-cp38-win32.whl", hash = "sha256:3a2ece436fa601d71c82f02aa5cf829692ebf77075c64d94af5dfd4a13f31166"}, + {file = "windows_curses-2.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:e716deaff73bbc7336769bdbf2c2b1c4f9ed0743ed6686927af134e7e6f3a699"}, + {file = "windows_curses-2.4.2-cp39-cp39-win32.whl", hash = "sha256:45b1a9cb17712f78ab4a6e9456bd46df35f96f880c1e814f8e0c6b4511a7e89c"}, + {file = "windows_curses-2.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:9de1714af3effdffb3a048dff691c1c1d36c289b8f765c96f148a3dcb9f5984c"}, ] [[package]] name = "yarl" -version = "1.18.3" +version = "1.25.1" description = "Yet another URL library" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34"}, - {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7"}, - {file = "yarl-1.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:602d98f2c2d929f8e697ed274fbadc09902c4025c5a9963bf4e9edfc3ab6f7ed"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c654d5207c78e0bd6d749f6dae1dcbbfde3403ad3a4b11f3c5544d9906969dde"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5094d9206c64181d0f6e76ebd8fb2f8fe274950a63890ee9e0ebfd58bf9d787b"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35098b24e0327fc4ebdc8ffe336cee0a87a700c24ffed13161af80124b7dc8e5"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3236da9272872443f81fedc389bace88408f64f89f75d1bdb2256069a8730ccc"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2c08cc9b16f4f4bc522771d96734c7901e7ebef70c6c5c35dd0f10845270bcd"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80316a8bd5109320d38eef8833ccf5f89608c9107d02d2a7f985f98ed6876990"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1e1cc06da1491e6734f0ea1e6294ce00792193c463350626571c287c9a704db"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fea09ca13323376a2fdfb353a5fa2e59f90cd18d7ca4eaa1fd31f0a8b4f91e62"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e3b9fd71836999aad54084906f8663dffcd2a7fb5cdafd6c37713b2e72be1760"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:757e81cae69244257d125ff31663249b3013b5dc0a8520d73694aed497fb195b"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1771de9944d875f1b98a745bc547e684b863abf8f8287da8466cf470ef52690"}, - {file = "yarl-1.18.3-cp310-cp310-win32.whl", hash = "sha256:8874027a53e3aea659a6d62751800cf6e63314c160fd607489ba5c2edd753cf6"}, - {file = "yarl-1.18.3-cp310-cp310-win_amd64.whl", hash = "sha256:93b2e109287f93db79210f86deb6b9bbb81ac32fc97236b16f7433db7fc437d8"}, - {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069"}, - {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193"}, - {file = "yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a"}, - {file = "yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1"}, - {file = "yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5"}, - {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50"}, - {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576"}, - {file = "yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285"}, - {file = "yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2"}, - {file = "yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477"}, - {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb"}, - {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa"}, - {file = "yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8"}, - {file = "yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d"}, - {file = "yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c"}, - {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:61e5e68cb65ac8f547f6b5ef933f510134a6bf31bb178be428994b0cb46c2a04"}, - {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe57328fbc1bfd0bd0514470ac692630f3901c0ee39052ae47acd1d90a436719"}, - {file = "yarl-1.18.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a440a2a624683108a1b454705ecd7afc1c3438a08e890a1513d468671d90a04e"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c7907c8548bcd6ab860e5f513e727c53b4a714f459b084f6580b49fa1b9cee"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4f6450109834af88cb4cc5ecddfc5380ebb9c228695afc11915a0bf82116789"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9ca04806f3be0ac6d558fffc2fdf8fcef767e0489d2684a21912cc4ed0cd1b8"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77a6e85b90a7641d2e07184df5557132a337f136250caafc9ccaa4a2a998ca2c"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6333c5a377c8e2f5fae35e7b8f145c617b02c939d04110c76f29ee3676b5f9a5"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b3c92fa08759dbf12b3a59579a4096ba9af8dd344d9a813fc7f5070d86bbab1"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4ac515b860c36becb81bb84b667466885096b5fc85596948548b667da3bf9f24"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:045b8482ce9483ada4f3f23b3774f4e1bf4f23a2d5c912ed5170f68efb053318"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a4bb030cf46a434ec0225bddbebd4b89e6471814ca851abb8696170adb163985"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54d6921f07555713b9300bee9c50fb46e57e2e639027089b1d795ecd9f7fa910"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1d407181cfa6e70077df3377938c08012d18893f9f20e92f7d2f314a437c30b1"}, - {file = "yarl-1.18.3-cp39-cp39-win32.whl", hash = "sha256:ac36703a585e0929b032fbaab0707b75dc12703766d0b53486eabd5139ebadd5"}, - {file = "yarl-1.18.3-cp39-cp39-win_amd64.whl", hash = "sha256:ba87babd629f8af77f557b61e49e7c7cac36f22f871156b91e10a6e9d4f829e9"}, - {file = "yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b"}, - {file = "yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426"}, + {file = "yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b"}, + {file = "yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b"}, + {file = "yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e"}, + {file = "yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707"}, + {file = "yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03"}, + {file = "yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa"}, + {file = "yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9"}, + {file = "yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233"}, + {file = "yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b"}, + {file = "yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3"}, + {file = "yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371"}, + {file = "yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c"}, + {file = "yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee"}, + {file = "yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f"}, + {file = "yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd"}, + {file = "yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25"}, + {file = "yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3"}, + {file = "yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7"}, ] [package.dependencies] idna = ">=2.0" multidict = ">=4.0" -propcache = ">=0.2.0" +propcache = ">=0.2.1" [extras] +ffmpeg = ["imageio-ffmpeg"] ssl = ["certifi"] [metadata] lock-version = "2.1" -python-versions = ">=3.10 <4.0" -content-hash = "66008fa225aea220c062e58b547d56492a867505c0fd225637c0d57af9e784ff" +python-versions = ">=3.14,<4.0" +content-hash = "9e5e1fd4fbf1db463ae9e99e5216452be540ab4f5e92cc5d2130694643683a2d" diff --git a/pyproject.toml b/pyproject.toml index c18eb67f..83c343a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,75 +1,89 @@ -[tool.poetry] +[project] name = "streamrip" -version = "2.2.0" +version = "2.4.2" description = "A fast, all-in-one music downloader for Qobuz, Deezer, Tidal, and SoundCloud" -authors = ["nathom "] +authors = [{ name = "nathom", email = "nathanthomas707@gmail.com" }] +maintainers = [{ name = "Stensel8" }] license = "GPL-3.0-only" readme = "README.md" -homepage = "https://github.com/nathom/streamrip" -repository = "https://github.com/nathom/streamrip" -include = ["src/config.toml"] +requires-python = ">=3.14,<4.0" keywords = ["hi-res", "free", "music", "download"] classifiers = [ - "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "mutagen>=1.47,<2", + "tomlkit>=0.13,<1", + "pathvalidate>=3.2,<4", + "simple-term-menu>=1.6,<2; sys_platform != 'win32'", + "pick>=2.4,<3; sys_platform == 'win32'", + "windows-curses>=2.4,<3; sys_platform == 'win32'", + "Pillow>=11,<13", + "deezer-py>=1.3.7,<2", + "pycryptodomex>=3.20,<4", + "m3u8>=6,<7", + "aiofiles>=24.1", + "aiohttp>=3.11,<4", + "aiolimiter>=1.2,<2", + "rich>=13.9", + "click>=8.1,<9", + "click-help-colors>=0.9.4,<0.10", + "requests>=2.32,<3", + # Captures the Qobuz user_auth_token from a real browser login so users + # never have to dig it out of DevTools by hand. The Chromium build it + # drives downloads itself automatically the first time it's needed. + "playwright>=1.49", ] + +[project.optional-dependencies] +# Use certifi's CA bundle instead of the system certificates. +ssl = ["certifi>=2024.7.4"] +# A prebuilt ffmpeg, for when there's no system ffmpeg (or none on PATH). +ffmpeg = ["imageio-ffmpeg>=0.5"] + +[project.scripts] +streamrip = "streamrip.rip:rip" + +[project.urls] +Homepage = "https://github.com/Stensel8/streamrip" +Repository = "https://github.com/Stensel8/streamrip" +"Bug Reports" = "https://github.com/Stensel8/streamrip/issues" +Upstream = "https://github.com/nathom/streamrip" + +[tool.poetry] packages = [{ include = "streamrip" }] -[tool.poetry.scripts] -rip = "streamrip.rip:rip" - -[tool.poetry.dependencies] -python = ">=3.10 <4.0" -mutagen = "^1.45.1" -tomlkit = "^0.7.2" -pathvalidate = "^2.4.1" -simple-term-menu = { version = "^1.2.1", platform = 'darwin|linux' } -pick = { version = "^2", platform = 'win32|cygwin' } -windows-curses = { version = "^2.2.0", platform = 'win32|cygwin' } -Pillow = ">=9,<11" -deezer-py = "1.3.6" -pycryptodomex = "^3.10.1" -appdirs = "^1.4.4" -m3u8 = "^0.9.0" -aiofiles = "^0.7" -aiohttp = "^3.9" -aiodns = "^3.0.0" -aiolimiter = "^1.1.0" -rich = "^13.6.0" -click-help-colors = "^0.9.2" -certifi = { version = "^2025.1.31", optional = true } - -[tool.poetry.urls] -"Bug Reports" = "https://github.com/nathom/streamrip/issues" - -[tool.poetry.dev-dependencies] -types-click = "^7.1.2" -types-Pillow = "^8.3.1" -ruff = "^0.1" -black = "^24" -isort = "^5.9.3" -flake8 = "^3.9.2" -setuptools = "^67.4.0" -pytest = "^7.4" -pytest-mock = "^3.11.1" -pytest-asyncio = "^0.21.1" +[tool.poetry.group.dev.dependencies] +ruff = ">=0.8" +pytest = ">=8.3" +pytest-mock = ">=3.14" +pytest-asyncio = ">=0.25" +certifi = ">=2024.7.4" [tool.pytest.ini_options] -minversion = "6.0" +minversion = "8.0" addopts = "-ra -q" testpaths = ["tests"] log_level = "DEBUG" -asyncio_mode = 'auto' +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" log_cli = true [build-system] -requires = ["poetry-core>=1.0.0"] +requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" +[tool.ruff] +target-version = "py314" + [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = ["E4", "E7", "E9", "F", "I", "ASYNC", "N", "RUF", "ERA001"] -ignore = [] +# os.path.isfile/getsize in async code are cheap stat calls, not I/O worth a +# thread hop. +ignore = ["ASYNC240"] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] @@ -78,6 +92,10 @@ unfixable = [] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +[tool.ruff.lint.per-file-ignores] +# Tests read small fixture files synchronously and keep explanatory comments. +"tests/**" = ["ASYNC230", "ERA001", "RUF012"] + [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "double" @@ -90,6 +108,3 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" - -[tool.poetry.extras] -ssl = ["certifi"] diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..7c4d5511 --- /dev/null +++ b/renovate.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":dependencyDashboard" + ], + "timezone": "Europe/Amsterdam", + "forkProcessing": "enabled", + "pinDigests": true, + "assigneesFromCodeOwners": true, + "reviewersFromCodeOwners": true, + "semanticCommits": "enabled", + "enabledManagers": [ + "poetry", + "github-actions" + ], + "labels": [ + "dependencies" + ], + "lockFileMaintenance": { + "enabled": true, + "schedule": [ + "before 6am on monday" + ] + }, + "packageRules": [ + { + "matchManagers": [ + "github-actions" + ], + "groupName": "GitHub Actions", + "addLabels": [ + "github-actions" + ] + }, + { + "matchManagers": [ + "poetry" + ], + "groupName": "Python packages", + "addLabels": [ + "python" + ] + }, + { + "description": "Minor, patch and digest updates merge themselves once CI is green", + "matchUpdateTypes": [ + "minor", + "patch", + "pin", + "digest", + "lockFileMaintenance" + ], + "automerge": true, + "automergeType": "pr" + }, + { + "description": "Major updates wait for a human", + "matchUpdateTypes": [ + "major" + ], + "automerge": false + } + ] +} diff --git a/streamrip/__init__.py b/streamrip/__init__.py index 08d9db88..97536c5f 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -1,5 +1,10 @@ +import http.client + from . import converter, db, exceptions, media, metadata from .config import Config +# Prevent failures on CDNs (like Qobuz/Akamai) that send >100 response headers +http.client._MAXHEADERS = 1000 # type: ignore[attr-defined] + __all__ = ["Config", "converter", "db", "exceptions", "media", "metadata"] -__version__ = "2.2.0" +__version__ = "2.4.2" diff --git a/streamrip/client/__init__.py b/streamrip/client/__init__.py index 4e5dd8cf..d977f0f5 100644 --- a/streamrip/client/__init__.py +++ b/streamrip/client/__init__.py @@ -1,4 +1,4 @@ -from .client import Client +from .client import Client, new_session from .deezer import DeezerClient from .downloadable import BasicDownloadable, Downloadable from .qobuz import QobuzClient @@ -6,11 +6,12 @@ from .tidal import TidalClient __all__ = [ + "BasicDownloadable", "Client", "DeezerClient", - "TidalClient", + "Downloadable", "QobuzClient", "SoundcloudClient", - "Downloadable", - "BasicDownloadable", + "TidalClient", + "new_session", ] diff --git a/streamrip/client/client.py b/streamrip/client/client.py index 53eb08a4..ff12222c 100644 --- a/streamrip/client/client.py +++ b/streamrip/client/client.py @@ -12,10 +12,17 @@ logger = logging.getLogger("streamrip") +# Firefox 83 (2020) stood out as ancient next to a real client's user agent. +# Chrome on Windows is the most common desktop UA on the web, so it blends in +# best; bump the version number every so often to keep it current. DEFAULT_USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36" ) +# Fail a stalled connection after 30s of silence, not aiohttp's default 5 minutes. +REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=30) + class Client(ABC): source: str @@ -49,18 +56,20 @@ def get_rate_limiter( else contextlib.nullcontext() ) - @staticmethod - async def get_session( - headers: dict | None = None, verify_ssl: bool = True - ) -> aiohttp.ClientSession: - if headers is None: - headers = {} - - # Get connector kwargs based on SSL verification setting - connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=verify_ssl) - connector = aiohttp.TCPConnector(**connector_kwargs) - return aiohttp.ClientSession( - headers={"User-Agent": DEFAULT_USER_AGENT} | headers, - connector=connector, - ) +def new_session( + verify_ssl: bool = True, timeout: aiohttp.ClientTimeout = REQUEST_TIMEOUT +) -> aiohttp.ClientSession: + """An HTTP session; every request streamrip makes goes through one.""" + connector = aiohttp.TCPConnector( + **get_aiohttp_connector_kwargs(verify_ssl=verify_ssl), + resolver=aiohttp.ThreadedResolver(), + ) + # trust_env: honour HTTP(S)_PROXY / ALL_PROXY like requests already + # does for the audio downloads (upstream #961). + return aiohttp.ClientSession( + headers={"User-Agent": DEFAULT_USER_AGENT}, + connector=connector, + timeout=timeout, + trust_env=True, + ) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 056463f9..d9d23155 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -1,10 +1,8 @@ import asyncio -import binascii -import hashlib import logging import deezer -from Cryptodome.Cipher import AES +import requests from ..config import Config from ..exceptions import ( @@ -12,7 +10,7 @@ MissingCredentialsError, NonStreamableError, ) -from .client import Client +from .client import Client, new_session from .downloadable import DeezerDownloadable logger = logging.getLogger("streamrip") @@ -28,29 +26,49 @@ class DeezerClient(Client): logged_in: True if logged in config: deezer local config session: aiohttp.ClientSession, used only for track downloads not API requests + max_favorites: upper bound for favorites pagination """ source = "deezer" max_quality = 2 + max_favorites = 10_000 def __init__(self, config: Config): self.global_config = config self.client = deezer.Deezer() self.logged_in = False self.config = config.session.deezer + self._album_cache = {} + self._quality_warned = False + + # Increase the deezer-py requests session pool well above max_connections. + # Each concurrent download spawns several API calls (metadata, track token, + # GW info…) via asyncio.to_thread(), so the actual number of simultaneous + # requests easily exceeds max_connections. pool_maxsize is just a ceiling — + # no memory is pre-allocated — so a generous value avoids the urllib3 + # "Connection pool is full" warning without any real cost. + # max_connections is -1 for "no limit", so never go below the default. + max_conn = max(config.session.downloads.max_connections, 10) + adapter = requests.adapters.HTTPAdapter( + pool_connections=max_conn, + pool_maxsize=max(max_conn * 4, 32), + max_retries=0, + ) + self.client.session.mount("https://", adapter) + self.client.session.mount("http://", adapter) async def login(self): # Used for track downloads - self.session = await self.get_session( + self.session = new_session( verify_ssl=self.global_config.session.downloads.verify_ssl ) arl = self.config.arl if not arl: raise MissingCredentialsError - success = self.client.login_via_arl(arl) + success = await asyncio.to_thread(self.client.login_via_arl, arl) if not success: - raise AuthenticationError + raise AuthenticationError("Invalid or expired Deezer ARL.") self.logged_in = True async def get_metadata(self, item_id: str, media_type: str) -> dict: @@ -72,39 +90,155 @@ async def get_track(self, item_id: str) -> dict: except Exception as e: raise NonStreamableError(e) - album_id = item["album"]["id"] + album_id = str(item["album"]["id"]) try: - album_metadata, album_tracks = await asyncio.gather( - asyncio.to_thread(self.client.api.get_album, album_id), - asyncio.to_thread(self.client.api.get_album_tracks, album_id), - ) + item["album"] = await self.get_album(album_id) except Exception as e: - logger.error(f"Error fetching album of track {item_id}: {e}") - return item + # Geo-restricted or removed albums: tag from the track metadata. + logger.debug(f"Album {album_id} unavailable for track {item_id}: {e}") - album_metadata["tracks"] = album_tracks["data"] - album_metadata["track_total"] = len(album_tracks["data"]) - item["album"] = album_metadata + if self.global_config.session.downloads.lyrics: + try: + lyrics_resp = await asyncio.to_thread( + self.client.gw.get_track_lyrics, item_id + ) + # Use unsynced lyrics for MP3, synced for others (FLAC, OPUS, etc) + conversion = self.global_config.session.conversion + if conversion.enabled and conversion.codec.upper() == "MP3": + item["lyrics"] = lyrics_resp.get("LYRICS_TEXT") or "" + else: + item["lyrics"] = self._format_synced_lyrics( + lyrics_resp.get("LYRICS_SYNC_JSON") + ) or (lyrics_resp.get("LYRICS_TEXT") or "") + except Exception as e: + logger.warning(f"Failed to get lyrics for {item_id}: {e}") return item + @staticmethod + def _format_synced_lyrics(sync_json: list[dict] | None) -> str: + """Convert Deezer's LYRICS_SYNC_JSON into LRC-formatted text.""" + if not sync_json: + return "" + lines = [] + for entry in sync_json: + timestamp = entry.get("lrc_timestamp") + if timestamp: + lines.append(f"{timestamp}{entry.get('line', '')}") + else: + lines.append("") + return "\n".join(lines) + async def get_album(self, item_id: str) -> dict: + item_id = str(item_id) + if item_id in self._album_cache: + logger.debug("Deezer album cache hit for album ID %s", item_id) + return self._album_cache[item_id] album_metadata, album_tracks = await asyncio.gather( asyncio.to_thread(self.client.api.get_album, item_id), asyncio.to_thread(self.client.api.get_album_tracks, item_id), + return_exceptions=True, ) + if isinstance(album_metadata, BaseException): + raise NonStreamableError(album_metadata) + if isinstance(album_tracks, BaseException): + # Old album ids redirect to a re-release on the website, and the + # API answers /album/ with the new album but has no + # /album//tracks (upstream #893). Follow the new id. + new_id = str(album_metadata.get("id", item_id)) + if new_id == item_id: + raise NonStreamableError(album_tracks) + logger.debug("Deezer album %s now lives at %s", item_id, new_id) + album_tracks = await asyncio.to_thread( + self.client.api.get_album_tracks, new_id + ) album_metadata["tracks"] = album_tracks["data"] album_metadata["track_total"] = len(album_tracks["data"]) + self._album_cache[item_id] = album_metadata return album_metadata async def get_playlist(self, item_id: str) -> dict: - pl_metadata, pl_tracks = await asyncio.gather( - asyncio.to_thread(self.client.api.get_playlist, item_id), - asyncio.to_thread(self.client.api.get_playlist_tracks, item_id), - ) - pl_metadata["tracks"] = pl_tracks["data"] - pl_metadata["track_total"] = len(pl_tracks["data"]) - return pl_metadata + if item_id.startswith("favorites:"): + user_id = item_id.removeprefix("favorites:") + return await self.get_user_favorites(user_id) + try: + pl_metadata, pl_tracks = await asyncio.gather( + asyncio.to_thread(self.client.api.get_playlist, item_id), + asyncio.to_thread(self.client.api.get_playlist_tracks, item_id), + ) + pl_metadata["tracks"] = pl_tracks["data"] + pl_metadata["track_total"] = len(pl_tracks["data"]) + return pl_metadata + except Exception as e: + # The public API refuses private playlists, and lately many public + # ones too, with a PermissionException (upstream #973, #945). The + # internal gw API accepts the ARL session and returns every track. + logger.debug( + "Public API refused playlist %s (%s), using gw API", item_id, e + ) + return await self._get_playlist_gw(item_id) + + async def _get_playlist_gw(self, item_id: str) -> dict: + try: + page, songs = await asyncio.gather( + asyncio.to_thread(self.client.gw.get_playlist_page, item_id), + asyncio.to_thread(self.client.gw.get_playlist_tracks, item_id), + ) + except Exception as e: + raise NonStreamableError( + f"Cannot access Deezer playlist {item_id}: {e}. Check that your " + "ARL is valid and that the playlist still exists." + ) + data = page.get("DATA", {}) if isinstance(page, dict) else {} + tracks = [{"id": str(t["SNG_ID"])} for t in songs if t.get("SNG_ID")] + return { + "id": item_id, + "title": data.get("TITLE") or f"Deezer playlist {item_id}", + "tracks": tracks, + "track_total": len(tracks), + } + + async def get_user_favorites(self, user_id: str) -> dict: + """Fetch the loved tracks for the authenticated Deezer account. + + ``song.getFavoriteIds`` silently caps responses at roughly 25 entries per + call regardless of the ``nb`` parameter; the ``start`` parameter must be + advanced by the actual count returned to paginate through all favorites. + + ``song.getFavoriteIds`` carries no ``user_id`` parameter — it always + returns the authenticated user's favorites. Comparing ``user_id`` against + the logged-in account to detect "other user" is unreliable for family + accounts: ``change_account()`` shifts ``current_user`` to a child profile + whose id differs from the main account's USER_ID that authenticated the + ARL. The ``user_id`` argument is accepted for URL-routing compatibility + but is not forwarded to the GW call. + + Args: + user_id: The Deezer user ID from the profile URL. Accepted for + routing compatibility; the GW call always uses the authenticated + account. + + Returns: + Playlist-shaped dict with "title", "tracks", and "track_total". + """ + page_size = 100 + all_entries: list[dict] = [] + start = 0 + while len(all_entries) < self.max_favorites: + response = await asyncio.to_thread( + self.client.gw.get_user_favorite_ids, limit=page_size, start=start + ) + entries: list[dict] = response.get("data", []) + if not entries: + break + all_entries.extend(entries) + start += len(entries) + + return { + "title": "Loved Tracks", + "tracks": [{"id": str(entry["SNG_ID"])} for entry in all_entries], + "track_total": len(all_entries), + } async def get_artist(self, item_id: str) -> dict: artist, albums = await asyncio.gather( @@ -115,7 +249,6 @@ async def get_artist(self, item_id: str) -> dict: return artist async def search(self, media_type: str, query: str, limit: int = 200) -> list[dict]: - # TODO: use limit parameter if media_type == "featured": try: if query: @@ -123,15 +256,15 @@ async def search(self, media_type: str, query: str, limit: int = 200) -> list[di else: search_function = self.client.api.get_editorial_releases except AttributeError: - raise Exception(f'Invalid editorical selection "{query}"') + raise Exception(f'Invalid editorial selection "{query}"') else: try: search_function = getattr(self.client.api, f"search_{media_type}") except AttributeError: raise Exception(f"Invalid media type {media_type}") - response = search_function(query, limit=limit) # type: ignore - if response["total"] > 0: + response = await asyncio.to_thread(search_function, query, limit=limit) # type: ignore + if response.get("total", 0) > 0: return [response] return [] @@ -146,12 +279,20 @@ async def get_downloadable( "No item id provided. This can happen when searching for fallback songs.", ) # TODO: optimize such that all of the ids are requested at once + # Deezer only has qualities 0-2; `streamrip --quality 3/4` used to IndexError. + quality = max(0, min(quality, self.max_quality)) dl_info: dict = {"quality": quality, "id": item_id} - track_info = self.client.gw.get_track(item_id) + track_info = await asyncio.to_thread(self.client.gw.get_track, item_id) fallback_id = track_info.get("FALLBACK", {}).get("SNG_ID") + # Preserved across the FILESIZE-based downgrade below. A fallback track + # is a different release with its own metadata, so it should be asked + # for at the quality the caller wanted -- not at one lowered by the + # zeroed FILESIZEs of the track being replaced. + requested_quality = quality + quality_map = [ (9, "MP3_128"), # quality 0 (3, "MP3_320"), # quality 1 @@ -161,24 +302,47 @@ async def get_downloadable( int(track_info.get(f"FILESIZE_{format}", 0)) for _, format in quality_map ] dl_info["quality_to_size"] = size_map - - # Check if requested quality is available + + # Never ask for more than the subscription allows. Deezer answers that + # with WrongLicense, which used to surface as "HiFi is required for + # quality 2" even when quality 1 was requested on a free account + # (upstream #1015). + account_max = self._account_max_quality() + if quality > account_max: + if not self.config.lower_quality_if_not_available: + raise NonStreamableError( + f"Your Deezer subscription does not allow quality {quality} " + f"({quality_map[quality][1]}); the maximum is {account_max}." + ) + if not self._quality_warned: + logger.warning( + "Your Deezer subscription allows at most quality %d (%s); " + "downloading at that quality instead of %d.", + account_max, + quality_map[account_max][1], + quality, + ) + self._quality_warned = True + quality = account_max + + # Check if requested quality is available for this track if size_map[quality] == 0: if self.config.lower_quality_if_not_available: - # Fallback to lower quality + wanted = quality while size_map[quality] == 0 and quality > 0: - logger.warning( - "The requested quality %s is not available. Falling back to quality %s", + quality -= 1 + if quality != wanted: + logger.info( + "Quality %s is not available for track %s, using %s", + wanted, + item_id, quality, - quality - 1, ) - quality -= 1 else: - # No fallback - raise error raise NonStreamableError( f"The requested quality {quality} is not available and fallback is disabled." ) - + # Update the quality in dl_info to reflect the final quality used dl_info["quality"] = quality @@ -187,12 +351,12 @@ async def get_downloadable( token = track_info["TRACK_TOKEN"] try: logger.debug("Fetching deezer url with token %s", token) - url = self.client.get_track_url(token, format_str) + url = await asyncio.to_thread(self.client.get_track_url, token, format_str) except deezer.WrongLicense: raise NonStreamableError( - "The requested quality is not available with your subscription. " - "Deezer HiFi is required for quality 2. Otherwise, the maximum " - "quality allowed is 1.", + f"Your Deezer subscription does not allow {format_str} downloads. " + "FLAC (quality 2) needs Deezer HiFi/Premium, MP3 320 (quality 1) " + "needs a paid plan.", ) except deezer.WrongGeolocation: if not is_retry and fallback_id: @@ -202,45 +366,45 @@ async def get_downloadable( ) if url is None: - url = self._get_encrypted_file_url( - item_id, - track_info["MD5_ORIGIN"], - track_info["MEDIA_VERSION"], + # No URL at any quality is the signature of a delisted old-catalog + # track: it has been superseded by another release, and Deezer + # names that release in FALLBACK.SNG_ID. Follow it, exactly as the + # geoblock branch above does -- there the API says "not here", here + # it says nothing at all. Recursing passes the fallback id as + # item_id, so dl_info["id"] follows the track actually served. + if not is_retry and fallback_id: + logger.debug( + "No download URL for track %s; retrying with fallback ID %s", + item_id, + fallback_id, + ) + return await self.get_downloadable( + fallback_id, requested_quality, is_retry=True + ) + + # This used to fall back to the legacy AES-ECB CDN at + # e-cdns-proxy-.dzcdn.net. Deezer has retired those hosts and + # none of the sixteen resolve any more, so the generated URL could + # only ever fail at download time with a DNS error pointing at the + # wrong culprit. Fail here instead, while we can still say why. + raise NonStreamableError( + "Deezer returned no download URL for this track at any quality, " + "and it has no fallback track (delisted?). The legacy CDN that " + "used to serve as a fallback has been retired.", ) dl_info["url"] = url logger.debug("dz track info: %s", track_info) return DeezerDownloadable(self.session, dl_info) - def _get_encrypted_file_url( - self, - meta_id: str, - track_hash: str, - media_version: str, - ): - logger.debug("Unable to fetch URL. Trying encryption method.") - format_number = 1 - - url_bytes = b"\xa4".join( - ( - track_hash.encode(), - str(format_number).encode(), - str(meta_id).encode(), - str(media_version).encode(), - ), - ) - url_hash = hashlib.md5(url_bytes).hexdigest() - info_bytes = bytearray(url_hash.encode()) - info_bytes.extend(b"\xa4") - info_bytes.extend(url_bytes) - info_bytes.extend(b"\xa4") - # Pad the bytes so that len(info_bytes) % 16 == 0 - padding_len = 16 - (len(info_bytes) % 16) - info_bytes.extend(b"." * padding_len) - - path = binascii.hexlify( - AES.new(b"jo6aey6haid2Teih", AES.MODE_ECB).encrypt(info_bytes), - ).decode("utf-8") - url = f"https://e-cdns-proxy-{track_hash[0]}.dzcdn.net/mobile/1/{path}" - logger.debug("Encrypted file path %s", url) - return url + def _account_max_quality(self) -> int: + """The best quality the logged-in Deezer account may stream.""" + user = getattr(self.client, "current_user", None) or {} + if not user.get("license_token"): + # Unknown (e.g. not logged in yet): let Deezer decide. + return self.max_quality + if user.get("can_stream_lossless"): + return 2 + if user.get("can_stream_hq"): + return 1 + return 0 diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 3b29b504..fa341674 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -3,12 +3,12 @@ import functools import hashlib import itertools -import json import logging import os import re import shutil import tempfile +import threading import time from abc import ABC, abstractmethod from dataclasses import dataclass @@ -23,11 +23,14 @@ from .. import converter from ..exceptions import NonStreamableError +from ..utils.ffmpeg_utils import find_ffmpeg logger = logging.getLogger("streamrip") BLOWFISH_SECRET = "g4el58wc0zvf9na1" +# Deezer encrypts the first 2048 bytes of every stripe of this many. +DEEZER_STRIPE = 3 * 2048 def generate_temp_path(url: str): @@ -37,29 +40,84 @@ def generate_temp_path(url: str): ) -async def fast_async_download(path, url, headers, callback): - """Synchronous download with yield for every 1MB read. +def _plain_headers(headers) -> dict[str, str]: + """Copy (aiohttp multidict) headers into a plain str dict for requests. - Using aiofiles/aiohttp resulted in a yield to the event loop for every 1KB, - which made file downloads CPU-bound. This resulted in a ~10MB max total download - speed. This fixes the issue by only yielding to the event loop for every 1MB read. + requests rejects multidict's ``istr`` keys/values ("Header part ... must be + of type str or bytes", upstream #941). """ - chunk_size: int = 2**17 # 131 KB - counter = 0 - yield_every = 8 # 1 MB - with open(path, "wb") as file: # noqa: ASYNC101 - with requests.get( # noqa: ASYNC100 - url, - headers=headers, - allow_redirects=True, - stream=True, - ) as resp: + return {str(k): str(v) for k, v in dict(headers or {}).items()} + + +def _blocking_download(path, url, headers, report, stop: threading.Event, resume: bool): + """Stream ``url`` into ``path`` with requests. + + Runs in a worker thread. With ``resume``, a partial file left by an + earlier attempt that broke off (IncompleteRead, upstream #951/#1022) is + continued with a Range request instead of starting over. Resuming is + opt-in: appending to an unrelated existing file (a re-tagged track, a + downscaled cover.jpg) would corrupt it. + """ + chunk_size = 2**17 # 131 KB + resume_pos = os.path.getsize(path) if resume and os.path.exists(path) else 0 + req_headers = dict(headers) + if resume_pos > 0: + req_headers["Range"] = f"bytes={resume_pos}-" + + with requests.get( + url, headers=req_headers, allow_redirects=True, stream=True, timeout=60 + ) as resp: + if resume_pos > 0 and resp.status_code == 416: + # Range Not Satisfiable: the file is already complete. + report(resume_pos) + return + resp.raise_for_status() + # Only append if the server honoured the Range request (206). A 200 + # means it is sending the whole file again, so start over. + if resume_pos > 0 and resp.status_code == 206: + mode = "ab" + report(resume_pos) + else: + mode = "wb" + with open(path, mode) as file: for chunk in resp.iter_content(chunk_size=chunk_size): + if stop.is_set(): + raise asyncio.CancelledError file.write(chunk) - callback(len(chunk)) - if counter % yield_every == 0: - await asyncio.sleep(0) - counter += 1 + report(len(chunk)) + + +async def fast_async_download(path, url, headers, callback, resume: bool = False): + """Download ``url`` to ``path`` without blocking the event loop. + + requests with large chunks is much faster than aiohttp's 1KB reads, but + calling it on the event loop froze every other download while one + connection was active (upstream PR #982). Run it in a worker thread and + hand progress back to the loop, since rich's display is not thread-safe. + """ + loop = asyncio.get_running_loop() + stop = threading.Event() + + def report(n: int): + try: + loop.call_soon_threadsafe(callback, n) + except RuntimeError: + pass # loop already closed (interpreter shutting down) + + try: + await asyncio.to_thread( + _blocking_download, + path, + url, + _plain_headers(headers), + report, + stop, + resume, + ) + except asyncio.CancelledError: + # The thread cannot be cancelled; tell it to stop at the next chunk. + stop.set() + raise @dataclass(slots=True) @@ -69,6 +127,8 @@ class Downloadable(ABC): extension: str source: str = "Unknown" _size_base: Optional[int] = None + # Set by Track for retries: continue a partial file instead of restarting. + resume: bool = False async def download(self, path: str, callback: Callable[[int], Any]): await self._download(path, callback) @@ -111,9 +171,12 @@ def __init__( self.extension = extension self._size = None self.source: str = source or "Unknown" + self.resume = False async def _download(self, path: str, callback): - await fast_async_download(path, self.url, self.session.headers, callback) + await fast_async_download( + path, self.url, self.session.headers, callback, resume=self.resume + ) class DeezerDownloadable(Downloadable): @@ -141,26 +204,26 @@ def __init__(self, session: aiohttp.ClientSession, info: dict): self.id = str(info["id"]) async def _download(self, path: str, callback): - # with requests.Session().get(self.url, allow_redirects=True) as resp: async with self.session.get(self.url, allow_redirects=True) as resp: resp.raise_for_status() self._size = int(resp.headers.get("Content-Length", 0)) - if self._size < 20000 and not self.url.endswith(".jpg"): + if self._size < 20000: + # Too small to be audio: an error message in its place. try: - info = await resp.json() - try: - # Usually happens with deezloader downloads - raise NonStreamableError(f"{info['error']} - {info['message']}") - except KeyError: - raise NonStreamableError(info) - - except json.JSONDecodeError: - raise NonStreamableError("File not found.") + info = await resp.json(content_type=None) + message = f"{info['error']} - {info['message']}" + except ValueError, KeyError, TypeError: + message = "File not found." + raise NonStreamableError(message) if self.is_encrypted.search(self.url) is None: logger.debug(f"Deezer file at {self.url} not encrypted.") await fast_async_download( - path, self.url, self.session.headers, callback + path, + self.url, + self.session.headers, + callback, + resume=getattr(self, "resume", False), ) else: blowfish_key = self._generate_blowfish_key(self.id) @@ -171,24 +234,30 @@ async def _download(self, path: str, callback): blowfish_key, ) - buf = bytearray() - async for data, _ in resp.content.iter_chunks(): - buf += data - callback(len(data)) - - encrypt_chunk_size = 3 * 2048 + # Decrypt as it arrives rather than holding the whole file in + # memory: only an incomplete stripe is ever kept back. + pending = bytearray() async with aiofiles.open(path, "wb") as audio: - buflen = len(buf) - for i in range(0, buflen, encrypt_chunk_size): - data = buf[i : min(i + encrypt_chunk_size, buflen)] - if len(data) >= 2048: - decrypted_chunk = ( - self._decrypt_chunk(blowfish_key, data[:2048]) - + data[2048:] - ) - else: - decrypted_chunk = data - await audio.write(decrypted_chunk) + async for data in resp.content.iter_chunked(2**17): + callback(len(data)) + pending += data + whole = len(pending) - len(pending) % DEEZER_STRIPE + if whole: + chunk = pending[:whole] + del pending[:whole] + await audio.write(self._decrypt(blowfish_key, chunk)) + await audio.write(self._decrypt(blowfish_key, pending)) + + @classmethod + def _decrypt(cls, key, data: bytearray) -> bytearray: + """Decrypt stripes from the start of the stream (or right after the + previous whole stripe): of every 3 * 2048 bytes, only the first 2048 + are encrypted. + """ + for i in range(0, len(data), DEEZER_STRIPE): + if len(data) - i >= 2048: + data[i : i + 2048] = cls._decrypt_chunk(key, data[i : i + 2048]) + return data @staticmethod def _decrypt_chunk(key, data): @@ -254,6 +323,7 @@ def __init__( self.downloadable = BasicDownloadable(session, url, self.extension, "tidal") async def _download(self, path: str, callback): + self.downloadable.resume = getattr(self, "resume", False) await self.downloadable._download(path, callback) if self.enc_key is not None: dec_bytes = await self._decrypt_mqa_file(path, self.enc_key) @@ -306,12 +376,110 @@ async def _decrypt_mqa_file(in_path, encryption_key): return dec_bytes +class TidalDASHDownloadable(Downloadable): + """Tidal hi-res tracks served as an MPEG-DASH manifest (upstream PR #998). + + The init segment and every media segment are fetched into a fragmented + MP4, which ffmpeg then remuxes (stream copy, bit-identical audio) into the + final container. + """ + + def __init__( + self, + session: aiohttp.ClientSession, + init_url: str, + segment_urls: list[str], + codec: str, + ): + self.session = session + self.source = "tidal" + self.url = init_url + self.init_url = init_url + self.segment_urls = segment_urls + self.extension = "flac" if codec.lower() in ("flac", "mqa") else "m4a" + self._size = None + + async def size(self) -> int: + """Total size of the init segment plus every media segment. + + HEAD-ing only self.url would report the size of the tiny init segment, + so the progress bar would jump past 100% immediately. Requests are + bounded: firing one per segment at once gets many of them refused. + """ + if self._size is not None: + return self._size + + sem = asyncio.Semaphore(8) + + async def content_length(url: str) -> int | None: + async with sem: + try: + async with self.session.head(url) as resp: + resp.raise_for_status() + return int(resp.headers.get("Content-Length", 0)) + except Exception: + return None + + sizes = await asyncio.gather( + *(content_length(u) for u in (self.init_url, *self.segment_urls)) + ) + known = [s for s in sizes if s] + if not known: + self._size = 0 + return 0 + # Segments are near-uniform; estimate the ones that did not answer. + average = sum(known) // len(known) + self._size = sum(known) + (len(sizes) - len(known)) * average + return self._size + + async def _download(self, path: str, callback): + ffmpeg_path = find_ffmpeg() + if ffmpeg_path is None: + raise NonStreamableError( + "FFmpeg is required to download Tidal hi-res (DASH) tracks. " + "Install it, or install streamrip's ffmpeg extra." + ) + tmp_path = path + ".dash.mp4" + try: + async with aiofiles.open(tmp_path, "wb") as f: + for url in (self.init_url, *self.segment_urls): + async with self.session.get(url) as resp: + resp.raise_for_status() + chunk = await resp.read() + await f.write(chunk) + callback(len(chunk)) + + proc = await asyncio.create_subprocess_exec( + ffmpeg_path, + "-i", + tmp_path, + "-c", + "copy", + "-y", + path, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0 or not os.path.isfile(path): + # Without this a failed remux is silent and the track would be + # recorded as downloaded with nothing on disk. + raise NonStreamableError( + f"ffmpeg failed to remux Tidal DASH stream (exit " + f"{proc.returncode}): {stderr.decode(errors='replace')[-300:]}" + ) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + class SoundcloudDownloadable(Downloadable): def __init__(self, session, info: dict): self.session = session self.file_type = info["type"] self.source = "soundcloud" - if self.file_type == "mp3": + if self.file_type in ("mp3", "progressive"): self.extension = "mp3" elif self.file_type == "original": self.extension = "flac" @@ -322,6 +490,10 @@ def __init__(self, session, info: dict): async def _download(self, path, callback): if self.file_type == "mp3": await self._download_mp3(path, callback) + elif self.file_type == "progressive": + await BasicDownloadable( + self.session, self.url, "mp3", source="soundcloud" + ).download(path, callback) else: await self._download_original(path, callback) @@ -330,9 +502,7 @@ async def _download_original(self, path: str, callback): self.session, self.url, "flac", source="soundcloud" ) await downloader.download(path, callback) - self.size = downloader.size - engine = converter.FLAC(path) - await engine.convert(path) + await converter.FLAC(path).convert() async def _download_mp3(self, path: str, callback): # TODO: make progress bar reflect bytes @@ -341,29 +511,33 @@ async def _download_mp3(self, path: str, callback): parsed_m3u = m3u8.loads(content) self._size = len(parsed_m3u.segments) - segment_count = len(parsed_m3u.segments) + # Segments finish in any order; each has its own path, so they're + # concatenated in playlist order, and all removed afterwards -- also + # when one fails, or they pile up in the temp dir. + segment_paths = [generate_temp_path(s.uri) for s in parsed_m3u.segments] tasks = [ - asyncio.create_task(self._download_segment(i, segment.uri)) - for i, segment in enumerate(parsed_m3u.segments) + asyncio.create_task(self._download_segment(segment.uri, tmp, callback)) + for segment, tmp in zip(parsed_m3u.segments, segment_paths) ] - - segment_paths: dict[int, str] = {} - for coro in asyncio.as_completed(tasks): - index, downloaded_path = await coro - segment_paths[index] = downloaded_path - callback(1) - - ordered_paths = [segment_paths[i] for i in range(segment_count)] - await concat_audio_files(ordered_paths, path, "mp3") - - async def _download_segment(self, index: int, segment_uri: str) -> tuple[int, str]: - tmp = generate_temp_path(segment_uri) + try: + await asyncio.gather(*tasks) + await concat_audio_files(segment_paths, path, "mp3") + finally: + for task in tasks: + task.cancel() + # Let cancelled downloads finish unwinding before deleting their + # files, or one could still create its file afterwards. + await asyncio.gather(*tasks, return_exceptions=True) + for tmp in segment_paths: + if os.path.exists(tmp): + os.remove(tmp) + + async def _download_segment(self, segment_uri: str, tmp: str, callback): async with self.session.get(segment_uri) as resp: resp.raise_for_status() async with aiofiles.open(tmp, "wb") as file: - content = await resp.content.read() - await file.write(content) - return index, tmp + await file.write(await resp.content.read()) + callback(1) async def size(self) -> int: if self.file_type == "mp3": @@ -380,8 +554,11 @@ async def concat_audio_files(paths: list[str], out: str, ext: str, max_files_ope Recurses log_{max_file_open}(len(paths)) times. """ - if shutil.which("ffmpeg") is None: - raise Exception("FFmpeg must be installed.") + ffmpeg_path = find_ffmpeg() + if ffmpeg_path is None: + raise Exception( + "FFmpeg is required. Install it, or install streamrip's ffmpeg extra." + ) # Base case if len(paths) == 1: @@ -396,7 +573,7 @@ async def concat_audio_files(paths: list[str], out: str, ext: str, max_files_ope outpaths = [ os.path.join( tempdir, - f"__streamrip_ffmpeg_{hash(paths[i*max_files_open])}.{ext}", + f"__streamrip_ffmpeg_{hash(paths[i * max_files_open])}.{ext}", ) for i in range(num_batches) ] @@ -410,7 +587,7 @@ async def concat_audio_files(paths: list[str], out: str, ext: str, max_files_ope proc_futures = [] for i in range(num_batches): command = ( - "ffmpeg", + ffmpeg_path, "-i", f"concat:{'|'.join(itertools.islice(it, max_files_open))}", "-acodec", @@ -419,19 +596,28 @@ async def concat_audio_files(paths: list[str], out: str, ext: str, max_files_ope "warning", outpaths[i], ) - fut = asyncio.create_subprocess_exec(*command, stderr=asyncio.subprocess.PIPE) + fut = asyncio.create_subprocess_exec( + *command, stdin=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE + ) proc_futures.append(fut) # Create all processes concurrently processes = await asyncio.gather(*proc_futures) # wait for all of them to finish - await asyncio.gather(*[p.communicate() for p in processes]) - for proc in processes: - if proc.returncode != 0: - raise Exception( - f"FFMPEG returned with status code {proc.returncode} error: {proc.stderr} output: {proc.stdout}", - ) + results = await asyncio.gather(*[p.communicate() for p in processes]) + try: + for proc, (_, stderr) in zip(processes, results): + if proc.returncode != 0: + raise Exception( + f"FFMPEG returned with status code {proc.returncode}: " + f"{stderr.decode(errors='replace')[-300:]}" + ) - # Recurse on remaining batches - await concat_audio_files(outpaths, out, ext) + # Recurse on remaining batches + await concat_audio_files(outpaths, out, ext) + finally: + # The last one was moved into place; the rest are intermediates. + for p in outpaths: + if os.path.exists(p): + os.remove(p) diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 734e2b82..f946899a 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -7,41 +7,37 @@ from collections import OrderedDict from typing import List, Optional -import aiohttp - from ..config import Config from ..exceptions import ( + APIError, AuthenticationError, - IneligibleError, InvalidAppIdError, InvalidAppSecretError, MissingCredentialsError, NonStreamableError, ) -from .client import Client +from .client import Client, new_session from .downloadable import BasicDownloadable, Downloadable logger = logging.getLogger("streamrip") QOBUZ_BASE_URL = "https://www.qobuz.com/api.json/0.2" -QOBUZ_FEATURED_KEYS = { - "most-streamed", - "recent-releases", - "best-sellers", - "press-awards", - "ideal-discography", - "editor-picks", - "most-featured", - "qobuzissims", - "new-releases", - "new-releases-full", - "harmonia-mundi", - "universal-classic", - "universal-jazz", - "universal-jeunesse", - "universal-chanson", -} + +def file_url_signature( + format_id: int, intent: str, track_id: str, timestamp: float, secret: str +) -> str: + """The request_sig Qobuz requires on track/getFileUrl. + + MD5 is dictated by the Qobuz API: this is a request signature, not a way + of storing a secret, and Qobuz rejects anything else. + """ + preimage = ( + f"trackgetFileUrlformat_id{format_id}intent{intent}" + f"track_id{track_id}{timestamp}{secret}" + ) + # codeql[py/weak-sensitive-data-hashing] Qobuz's API mandates MD5 here. + return hashlib.md5(preimage.encode("utf-8")).hexdigest() class QobuzSpoofer: @@ -126,13 +122,7 @@ async def get_app_id_and_secrets(self) -> tuple[str, list[str]]: return app_id, secrets_list async def __aenter__(self): - from ..utils.ssl_utils import get_aiohttp_connector_kwargs - - # For the spoofer, always use SSL verification - connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=True) - connector = aiohttp.TCPConnector(**connector_kwargs) - - self.session = aiohttp.ClientSession(connector=connector) + self.session = new_session(verify_ssl=self.verify_ssl) return self async def __aexit__(self, *_): @@ -152,17 +142,28 @@ def __init__(self, config: Config): config.session.downloads.requests_per_minute, ) self.secret: Optional[str] = None + # True for a free account (no streaming subscription) that can still + # download content it has *purchased* from the Qobuz download store. + # When set, file-url requests use intent=download instead of stream. + self.download_only: bool = False async def login(self): - self.session = await self.get_session( - verify_ssl=self.config.session.downloads.verify_ssl - ) """User credentials require either a user token OR a user email & password. A hash of the password is stored in self.config.qobuz.password_or_token. This data as well as the app_id is passed to self._get_user_auth_token() to get the actual credentials for the user. """ + self.session = new_session(verify_ssl=self.config.session.downloads.verify_ssl) + try: + await self._login() + except BaseException: + # Close the session so a failed login does not leave an unclosed + # connector behind (and a re-login attempt starts from scratch). + await self.session.close() + raise + + async def _login(self): c = self.config.session.qobuz if not c.email_or_userid or not c.password_or_token: raise MissingCredentialsError @@ -171,13 +172,33 @@ async def login(self): if not c.app_id or not c.secrets: logger.info("App id/secrets not found, fetching") - c.app_id, c.secrets = await self._get_app_id_and_secrets() - # write to file - f = self.config.file - f.qobuz.app_id = c.app_id - f.qobuz.secrets = c.secrets - f.set_modified() + await self._refresh_app_id_and_secrets() + + # A stale app_id/secret pair (e.g. hardcoded in the config after Qobuz + # rotated its app secret) fails with InvalidAppIdError or + # InvalidAppSecretError. Re-fetch a fresh pair from the bundle and retry + # once before giving up. + try: + await self._attempt_login() + except (InvalidAppIdError, InvalidAppSecretError) as e: + logger.warning("Login failed with %s, refetching app id/secrets", e) + self.session.headers.pop("X-User-Auth-Token", None) + await self._refresh_app_id_and_secrets() + await self._attempt_login() + + self.logged_in = True + async def _refresh_app_id_and_secrets(self): + c = self.config.session.qobuz + c.app_id, c.secrets = await self._get_app_id_and_secrets() + # write to file + f = self.config.file + f.qobuz.app_id = c.app_id + f.qobuz.secrets = c.secrets + f.set_modified() + + async def _attempt_login(self): + c = self.config.session.qobuz self.session.headers.update({"X-App-Id": str(c.app_id)}) if c.use_auth_token: @@ -193,27 +214,51 @@ async def login(self): "app_id": str(c.app_id), } - logger.debug("Request params %s", params) + logger.debug( + "Logging into Qobuz with %s", + "a token" if c.use_auth_token else "a password", + ) status, resp = await self._api_request("user/login", params) - logger.debug("Login resp: %s", resp) + # The response carries the user_auth_token and the account profile. + logger.debug("Login response keys: %s", sorted(resp)) if status == 401: - raise AuthenticationError(f"Invalid credentials from params {params}") + if c.use_auth_token: + raise AuthenticationError( + "Invalid Qobuz user id or user_auth_token. The token may have " + "expired; log in again to get a fresh one." + ) + raise AuthenticationError( + "Invalid Qobuz email or password. Qobuz has moved its web login " + "behind a captcha, so password login may no longer work; log in " + "with a user id and user_auth_token instead." + ) elif status == 400: - raise InvalidAppIdError(f"Invalid app id from params {params}") + raise InvalidAppIdError(f"Qobuz rejected app id {c.app_id}") + elif status != 200: + raise APIError( + f"Qobuz login failed (HTTP {status}): " + f"{resp.get('message') or 'no message'}" + ) logger.debug("Logged in to Qobuz") + # An empty credential.parameters means the account has no active + # streaming subscription. Such a (free) account cannot stream, but it + # CAN still download albums it has purchased from the Qobuz download + # store, so flag the client as download-only instead of refusing. if not resp["user"]["credential"]["parameters"]: - raise IneligibleError("Free accounts are not eligible to download tracks.") + self.download_only = True + logger.warning( + "Free Qobuz account detected (no streaming subscription): only " + "purchased download-store content can be downloaded." + ) uat = resp["user_auth_token"] self.session.headers.update({"X-User-Auth-Token": uat}) self.secret = await self._get_valid_secret(c.secrets) - self.logged_in = True - async def get_metadata(self, item: str, media_type: str): if media_type == "label": return await self.get_label(item) @@ -229,8 +274,13 @@ async def get_metadata(self, item: str, media_type: str): extras = { "artist": "albums", - "playlist": "tracks", + # tracks may come back empty (July 2026 API change); track_ids is + # the replacement. Request both so either response shape works. + "playlist": "tracks,track_ids", "label": "albums", + # Qobuz's album/get stopped inlining "tracks" (July 2026); request + # the id list instead. Consumed by get_album_track_ids(). + "album": "track_ids", } if media_type in extras: @@ -244,11 +294,41 @@ async def get_metadata(self, item: str, media_type: str): if status != 200: raise NonStreamableError( - f'Error fetching metadata. Message: "{resp["message"]}"', + f'Error fetching metadata. Message: "{resp.get("message")}"', ) + if media_type == "playlist": + await self._fetch_remaining_playlist_tracks(epoint, params, resp) + return resp + async def _fetch_remaining_playlist_tracks( + self, epoint: str, params: dict, resp: dict + ): + """Page through playlists longer than one response (500 tracks). + + Only needed when Qobuz did not return the complete ``track_ids`` list, + which PlaylistMetadata prefers when present. + """ + if resp.get("track_ids"): + return + tracks = resp.get("tracks") or {} + items = tracks.get("items") or [] + total = int(tracks.get("total") or resp.get("tracks_count") or 0) + if not items or total <= len(items): + return + + limit = int(params.get("limit", 500)) + pages = await asyncio.gather( + *[ + self._request_ok(epoint, {**params, "offset": offset}) + for offset in range(len(items), total, limit) + ] + ) + for page in pages: + items.extend((page.get("tracks") or {}).get("items") or []) + logger.debug("Fetched %d/%d playlist tracks", len(items), total) + async def get_label(self, label_id: str) -> dict: c = self.config.session.qobuz page_limit = 500 @@ -300,36 +380,42 @@ async def search(self, media_type: str, query: str, limit: int = 500) -> list[di return await self._paginate(epoint, params, limit=limit) - async def get_featured(self, query, limit: int = 500) -> list[dict]: - params = { - "type": query, - } - assert query in QOBUZ_FEATURED_KEYS, f'query "{query}" is invalid.' - epoint = "album/getFeatured" - return await self._paginate(epoint, params, limit=limit) - - async def get_user_favorites(self, media_type: str, limit: int = 500) -> list[dict]: - assert media_type in ("track", "artist", "album") - params = {"type": f"{media_type}s"} - epoint = "favorite/getUserFavorites" - - return await self._paginate(epoint, params, limit=limit) - - async def get_user_playlists(self, limit: int = 500) -> list[dict]: - epoint = "playlist/getUserPlaylists" - return await self._paginate(epoint, {}, limit=limit) - async def get_downloadable(self, item: str, quality: int) -> Downloadable: - assert self.secret is not None and self.logged_in and 1 <= quality <= 4 + assert self.secret is not None and self.logged_in + # Qobuz has no quality 0 (128 kbps); clamp instead of asserting so + # `streamrip --quality 0` still works for mixed-source downloads. + quality = max(1, min(quality, self.max_quality)) status, resp_json = await self._request_file_url(item, quality, self.secret) - assert status == 200 + if status != 200: + raise NonStreamableError( + f"Could not get a download URL (HTTP {status}): " + f"{resp_json.get('message') or 'no message'}" + ) stream_url = resp_json.get("url") if stream_url is None: - restrictions = resp_json["restrictions"] + restrictions = resp_json.get("restrictions") if restrictions: + code = restrictions[0]["code"] + # Purchased (download-only) content is sold in exactly one + # format and Qobuz offers no automatic fallback: requesting a + # higher tier than the purchased one fails with + # FormatRestrictedByFormatAvailability. Step down one tier. + if ( + self.download_only + and quality > 1 + and code == "FormatRestrictedByFormatAvailability" + ): + logger.warning( + "Quality %d unavailable for purchased track %s; " + "retrying at quality %d.", + quality, + item, + quality - 1, + ) + return await self.get_downloadable(item, quality - 1) # Turn CamelCase code into a readable sentence - words = re.findall(r"([A-Z][a-z]+)", restrictions[0]["code"]) + words = re.findall(r"([A-Z][a-z]+)", code) raise NonStreamableError( words[0] + " " + " ".join(map(str.lower, words[1:])) + ".", ) @@ -339,6 +425,35 @@ async def get_downloadable(self, item: str, quality: int) -> Downloadable: self.session, stream_url, "flac" if quality > 1 else "mp3", source="qobuz" ) + async def _request_ok(self, epoint: str, params: dict) -> dict: + """_api_request that insists on HTTP 200, retrying once if Qobuz blips. + + Qobuz's search backend fails intermittently -- a 400 reading + "Impossible to connect, please check your Algolia Application Id." + that succeeds moments later -- and its edge sometimes answers with a + 502 HTML page. One short retry absorbs those; anything else is raised + with Qobuz's own message rather than a bare AssertionError. + """ + for attempt in (1, 2): + status, page = await self._api_request(epoint, params) + if status == 200: + return page + message = (page.get("message") if isinstance(page, dict) else None) or "" + transient = status >= 500 or "Algolia" in message + if attempt == 1 and transient: + logger.warning( + "Qobuz %s failed (HTTP %d: %s) -- retrying once", + epoint, + status, + message or "no message", + ) + await asyncio.sleep(3) + continue + break + raise APIError( + f"Qobuz {epoint} failed (HTTP {status}): {message or 'no message'}" + ) + async def _paginate( self, epoint: str, @@ -356,9 +471,8 @@ async def _paginate( Generator that yields (status code, response) tuples """ params.update({"limit": limit}) - status, page = await self._api_request(epoint, params) - assert status == 200, status - logger.debug("paginate: initial request made with status %d", status) + page = await self._request_ok(epoint, params) + logger.debug("paginate: initial request succeeded") # albums, tracks, etc. key = epoint.split("/")[0] + "s" items = page.get(key, {}) @@ -380,17 +494,13 @@ async def _paginate( pages = [] requests = [] - assert status == 200, status pages.append(page) while (offset + limit) < total: offset += limit params.update({"offset": offset}) - requests.append(self._api_request(epoint, params.copy())) - - for status, resp in await asyncio.gather(*requests): - assert status == 200 - pages.append(resp) + requests.append(self._request_ok(epoint, params.copy())) + pages.extend(await asyncio.gather(*requests)) return pages async def _get_app_id_and_secrets(self) -> tuple[str, list[str]]: @@ -426,16 +536,18 @@ async def _request_file_url( ) -> tuple[int, dict]: quality = self.get_quality(quality) unix_ts = time.time() - r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}" - logger.debug("Raw request signature: %s", r_sig) - r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() - logger.debug("Hashed request signature: %s", r_sig_hashed) + # Owned-only (free) accounts must request intent=download; streaming + # accounts use intent=stream. The signed preimage and the params dict + # MUST agree on the value or Qobuz rejects the request with HTTP 400. + intent = "download" if self.download_only else "stream" params = { "request_ts": unix_ts, - "request_sig": r_sig_hashed, + "request_sig": file_url_signature( + quality, intent, track_id, unix_ts, secret + ), "track_id": track_id, "format_id": quality, - "intent": "stream", + "intent": intent, } return await self._api_request("track/getFileUrl", params) @@ -444,9 +556,19 @@ async def _api_request(self, epoint: str, params: dict) -> tuple[int, dict]: returns: status code, json parsed response """ url = f"{QOBUZ_BASE_URL}/{epoint}" - logger.debug("api_request: endpoint=%s, params=%s", epoint, params) + # Only the endpoint: params carry credentials (user_auth_token, password) + # and the request signature. + logger.debug("api_request: endpoint=%s", epoint) async with self.rate_limiter: async with self.session.get(url, params=params) as response: + if "json" not in (response.content_type or ""): + # An HTML error page, such as a 502 from Qobuz's edge. + # aiohttp's ContentTypeError would quote the full request + # URL, which carries user_auth_token -- so report the + # status instead of letting that propagate. + return response.status, { + "message": f"non-JSON response ({response.content_type})" + } return response.status, await response.json() @staticmethod diff --git a/streamrip/client/soundcloud.py b/streamrip/client/soundcloud.py index f10ec7cf..c6b303be 100644 --- a/streamrip/client/soundcloud.py +++ b/streamrip/client/soundcloud.py @@ -6,7 +6,7 @@ from ..config import Config from ..exceptions import NonStreamableError -from .client import Client +from .client import Client, new_session from .downloadable import SoundcloudDownloadable # e.g. 123456-293847-121314-209849 @@ -36,11 +36,13 @@ def __init__(self, config: Config): ) async def login(self): - self.session = await self.get_session( + self.session = new_session( verify_ssl=self.global_config.session.downloads.verify_ssl ) client_id, app_version = self.config.client_id, self.config.app_version - if not client_id or not app_version or not (await self._announce_success()): + # app_version is optional: SoundCloud does not always expose it, and + # requiring it made every run re-scrape the website. + if not client_id or not (await self._announce_success()): client_id, app_version = await self._refresh_tokens() # update file and session configs and save to disk cf = self.global_config.file.soundcloud @@ -128,11 +130,15 @@ async def get_downloadable(self, item_info: str, _) -> SoundcloudDownloadable: if download_info == self.NOT_RESOLVED: raise NotImplementedError(item_info) - # download_info contains mp3 stream url + # download_info contains a transcoding url, which resolves to the + # actual (signed) media url resp_json, status = await self._request(download_info) + if status != 200 or "url" not in resp_json: + raise NonStreamableError(f"Could not resolve stream for track {item_id}") + file_type = "progressive" if "/progressive" in download_info else "mp3" return SoundcloudDownloadable( self.session, - {"url": resp_json["url"], "type": "mp3"}, + {"url": resp_json["url"], "type": file_type}, ) async def resolve_url(self, url: str) -> dict: @@ -214,27 +220,41 @@ def _get_custom_id(cls, resp: dict) -> str: if resp["downloadable"] and resp["has_downloads_left"]: return f"{item_id}|{cls.ORIGINAL_DOWNLOAD}" + # Prefer the plain progressive MP3 (one file), then HLS MP3 segments. + # SoundCloud is phasing out MP3 HLS for many tracks, which used to trip + # an assertion here. + transcodings = resp["media"].get("transcodings") or [] url = None - for tc in resp["media"]["transcodings"]: - fmt = tc["format"] - if fmt["protocol"] == "hls" and fmt["mime_type"] == "audio/mpeg": - url = tc["url"] + for protocol in ("progressive", "hls"): + for tc in transcodings: + fmt = tc.get("format") or {} + if ( + fmt.get("protocol") == protocol + and fmt.get("mime_type") == "audio/mpeg" + and not tc.get("snipped") + ): + url = tc["url"] + break + if url is not None: break - assert url is not None + if url is None: + return f"{item_id}|{cls.NON_STREAMABLE}" return f"{item_id}|{url}" + def _auth_params(self) -> dict: + c = self.config + params = {"client_id": c.client_id, "app_locale": "en"} + if c.app_version: + params["app_version"] = c.app_version + return params + async def _api_request(self, path, params=None, headers=None): url = f"{BASE}/{path}" return await self._request(url, params=params, headers=headers) async def _request(self, url, params=None, headers=None) -> tuple[dict, int]: - c = self.config - _params = { - "client_id": c.client_id, - "app_version": c.app_version, - "app_locale": "en", - } + _params = self._auth_params() if params is not None: _params.update(params) @@ -243,12 +263,7 @@ async def _request(self, url, params=None, headers=None) -> tuple[dict, int]: return await resp.json(), resp.status async def _request_body(self, url, params=None, headers=None): - c = self.config - _params = { - "client_id": c.client_id, - "app_version": c.app_version, - "app_locale": "en", - } + _params = self._auth_params() if params is not None: _params.update(params) @@ -261,37 +276,56 @@ async def _announce_success(self): return status == 200 async def _refresh_tokens(self) -> tuple[str, str]: - """Return a valid client_id, app_version pair.""" + """Return a valid client_id, app_version pair. + + The client id is embedded in one of the JavaScript bundles loaded by + soundcloud.com. Rather than relying on the exact markup of the script + tags (which changed and broke this, upstream #1038), try every bundle, + newest last, the way yt-dlp does. + """ async with self.session.get(STOCK_URL) as resp: page_text = await resp.text(encoding="utf-8") - *_, client_id_url_match = re.finditer( - r"]+src="([^"]+)"', page_text) + for script_url in reversed(script_urls): + if script_url.startswith("//"): + script_url = "https:" + script_url + elif script_url.startswith("/"): + script_url = STOCK_URL.rstrip("/") + script_url + try: + async with self.session.get(script_url) as resp: + script = await resp.text(encoding="utf-8") + except Exception as e: + logger.debug("Could not fetch %s: %s", script_url, e) + continue + client_id = _find_client_id(script) + if client_id is not None: + logger.debug( + f"Refreshed soundcloud tokens as {client_id=} {app_version=}" + ) + return client_id, app_version + + raise Exception( + f"Could not find a SoundCloud client id in {len(script_urls)} scripts " + f"on {STOCK_URL}. SoundCloud may have changed its website." ) - if client_id_url_match is None: - raise Exception("Could not find client ID in %s" % STOCK_URL) - - client_id_url = client_id_url_match.group(1) - - app_version_match = re.search( - r'', - page_text, - ) - if app_version_match is None: - raise Exception("Could not find app version in %s" % client_id_url_match) - app_version = app_version_match.group(1) - async with self.session.get(client_id_url) as resp: - page_text2 = await resp.text(encoding="utf-8") +CLIENT_ID_REGEXES = ( + re.compile(r'client_id\s*:\s*"([0-9a-zA-Z]{32})"'), + re.compile(r"client_id=([0-9a-zA-Z]{32})"), +) - client_id_match = re.search(r'client_id:\s*"(\w+)"', page_text2) - assert client_id_match is not None - client_id = client_id_match.group(1) - logger.debug(f"Refreshed soundcloud tokens as {client_id=} {app_version=}") - return client_id, app_version +def _find_client_id(script: str) -> str | None: + for regex in CLIENT_ID_REGEXES: + match = regex.search(script) + if match is not None: + return match.group(1) + return None def batched(iterable, n, fillvalue=None): diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 92455d20..ad7e4a35 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -2,37 +2,133 @@ import base64 import json import logging -import re +import random import time from json import JSONDecodeError import aiohttp from ..config import Config -from ..exceptions import NonStreamableError -from .client import Client -from .downloadable import TidalDownloadable +from ..exceptions import ( + AuthenticationError, + ItemNotFoundError, + MissingCredentialsError, + NonStreamableError, +) +from ..metadata.util import tidal_quality_id +from .client import Client, new_session +from .downloadable import TidalDASHDownloadable, TidalDownloadable logger = logging.getLogger("streamrip") BASE = "https://api.tidalhifi.com/v1" AUTH_URL = "https://auth.tidal.com/v1/oauth2" -CLIENT_ID = base64.b64decode("ZlgySnhkbW50WldLMGl4VA==").decode("iso-8859-1") -CLIENT_SECRET = base64.b64decode( - "MU5tNUFmREFqeHJnSkZKYktOV0xlQXlLR1ZHbUlOdVhQUExIVlhBdnhBZz0=", -).decode("iso-8859-1") -AUTH = aiohttp.BasicAuth(login=CLIENT_ID, password=CLIENT_SECRET) -STREAM_URL_REGEX = re.compile( - r"#EXT-X-STREAM-INF:BANDWIDTH=\d+,AVERAGE-BANDWIDTH=\d+,CODECS=\"(?!jpeg)[^\"]+\",RESOLUTION=\d+x\d+\n(.+)" -) +def _b64(s: str) -> str: + return base64.b64decode(s).decode("iso-8859-1") + + +# Tidal decides which formats a client may stream per OAuth client id, and no +# single id is known to be served everything (see upstream PR #1017): +# +# default FLAC 16/44.1 for every lossless release, never hi-res +# hi-res 24-bit FLAC (MPEG-DASH, up to 192 kHz) for hi-res releases, but +# AAC 320 for ordinary lossless releases -- whatever is asked for +# +# So streamrip logs in with both (`hires_client`, on by default): each track is +# asked of the hi-res client first, and of the default one when it has no hi-res +# master. client_id/client_secret replace the default one (e.g. after Tidal +# revokes it). +DEFAULT_CLIENT_ID = _b64("NE4zbjZRMXg5NUxMNUs3cA==") +DEFAULT_CLIENT_SECRET = _b64( + "b0tPWGZKVzM3MWNYNnhhWjBQeWhnR05CZE5MbEJaZDRBS0tZb3VnTWppaz0=" +) +HIRES_CLIENT_ID = _b64("ZlgySnhkbW50WldLMGl4VA==") +HIRES_CLIENT_SECRET = _b64( + "MU5tNUFmREFqeHJnSkZKYktOV0xlQXlLR1ZHbUlOdVhQUExIVlhBdnhBZz0=" +) QUALITY_MAP = { 0: "LOW", # AAC 1: "HIGH", # AAC 2: "LOSSLESS", # CD Quality - 3: "HI_RES", # MQA + 3: "HI_RES", # Best available: 24-bit FLAC (DASH) where the client may +} + +# Tidal decides what it actually serves per track/client, independent of what +# was requested (e.g. no lossless master for that particular track), and +# says so in the response's own "audioQuality" -- not always the same string +# as what was requested (HI_RES_LOSSLESS in the response vs. HI_RES in the +# request). +_AUDIO_QUALITY_TIER = { + "LOW": 0, + "HIGH": 1, + "LOSSLESS": 2, + "HI_RES": 3, + "HI_RES_LOSSLESS": 3, } +LOSSLESS_TIER = _AUDIO_QUALITY_TIER["LOSSLESS"] +HIRES_TIER = _AUDIO_QUALITY_TIER["HI_RES"] +DASH_MIME = "application/dash+xml" + +# Rate limiting and server errors are worth another attempt; other 4xx are not. +RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) +MAX_API_ATTEMPTS = 4 +MAX_RETRY_DELAY = 60 +RATE_LIMIT_PAUSE = 10 # seconds per attempt when a 429 has no Retry-After + + +def _retry_after(resp, attempt: int) -> float: + try: + seconds = max(float(resp.headers["Retry-After"]), 1.0) + except KeyError, ValueError: + seconds = RATE_LIMIT_PAUSE * attempt + return min(seconds, MAX_RETRY_DELAY) + + +def _dedup_duplicate_albums(albums: list[dict]) -> list[dict]: + """Keep only the best copy when Tidal lists the same release twice. + + Tidal sometimes lists one album under an artist more than once: a clean + and an explicit master, or just the same master at two quality tiers + (a lossless entry and a separate hi-res one). Grouped by title and + track count -- matching on both is as good as certain to be the same + release, not two different albums that happen to share a title -- and + the explicit, higher-quality copy is kept. + """ + # Tidal's own catalog is inconsistent about which bracket style tags an + # edition name, so square and round brackets are folded together before + # grouping -- otherwise the same release under each style looks distinct. + brackets = str.maketrans("[]", "()") + + groups: dict[tuple[str, int], list[dict]] = {} + for album in albums: + key = ( + (album.get("title") or "").strip().lower().translate(brackets), + album.get("numberOfTracks", 0), + ) + groups.setdefault(key, []).append(album) + + def best(album: dict) -> tuple[bool, int]: + return (bool(album.get("explicit")), tidal_quality_id(album.get("audioQuality"))) + + return [max(group, key=best) for group in groups.values()] + + +class _Tokens: + """One lane's login tokens, stored in [tidal] under an optional prefix.""" + + FIELDS = ("access_token", "refresh_token", "token_expiry", "token_client_id") + + def __init__(self, section, prefix: str): + object.__setattr__(self, "_section", section) + object.__setattr__(self, "_prefix", prefix) + + def __getattr__(self, name): + return getattr(self._section, self._prefix + name) + + def __setattr__(self, name, value): + setattr(self._section, self._prefix + name, value) class TidalClient(Client): @@ -40,30 +136,103 @@ class TidalClient(Client): source = "tidal" max_quality = 3 + # time.monotonic() before which no request is sent (set by a 429) + _retry_at = 0.0 - def __init__(self, config: Config): + def __init__(self, config: Config, hires_of: "TidalClient | None" = None): self.logged_in = False self.global_config = config self.config = config.session.tidal - self.rate_limiter = self.get_rate_limiter( - config.session.downloads.requests_per_minute, - ) + c = self.config + # Tidal serves each OAuth client other formats and no single one serves + # them all (see above). With `hires_client` there are two lanes, each + # with its own login: this one is asked for hi-res first, and the + # lossless one when Tidal has no hi-res master for the track. The + # hi-res lane keeps its tokens in the `hires_*` fields of [tidal]. + self._token_prefix = "hires_" if hires_of else "" + self.tokens = _Tokens(c, self._token_prefix) + self._root = hires_of or self + if hires_of: + self.rate_limiter = hires_of.rate_limiter + self.client_id, self.client_secret = HIRES_CLIENT_ID, HIRES_CLIENT_SECRET + self.hires_lane = None + else: + self.rate_limiter = self.get_rate_limiter( + config.session.downloads.requests_per_minute, + ) + if c.client_id and c.client_secret: + self.client_id, self.client_secret = c.client_id, c.client_secret + else: + self.client_id = DEFAULT_CLIENT_ID + self.client_secret = DEFAULT_CLIENT_SECRET + # Only worth a second login when hi-res is what is asked for. + wants_hires = c.hires_client and c.quality >= HIRES_TIER + self.hires_lane = ( + TidalClient(config, hires_of=self) if wants_hires else None + ) + # Tracks Tidal says have no hi-res master; see _note_hires_tags. + self._no_hires: set[str] = set() + # HTTP Basic auth for the token endpoint. Built by hand because + # aiohttp.BasicAuth is deprecated as of aiohttp 3.14. + credentials = f"{self.client_id}:{self.client_secret}".encode() + self.auth_headers = { + "Authorization": "Basic " + base64.b64encode(credentials).decode() + } + + def lanes(self) -> list["TidalClient"]: + """This client and, with `hires_client`, the hi-res one behind it.""" + return [self, self.hires_lane] if self.hires_lane else [self] + + async def close(self): + for lane in self.lanes(): + if getattr(lane, "session", None) is not None: + await lane.session.close() + + def save_login(self): + """Copy this lane's tokens to the config file's copy, written on exit.""" + f = self.global_config.file + saved = _Tokens(f.tidal, self._token_prefix) + for name in _Tokens.FIELDS: + setattr(saved, name, getattr(self.tokens, name)) + f.set_modified() async def login(self): - self.session = await self.get_session( - verify_ssl=self.global_config.session.downloads.verify_ssl - ) - c = self.config - if not c.access_token: - raise Exception("Access token not found in config.") + """Log every lane in. Any lane missing or with a lapsed login raises.""" + for lane in self.lanes(): + await lane._login_lane() + self.logged_in = True - self.token_expiry = float(c.token_expiry) - self.refresh_token = c.refresh_token + async def _login_lane(self): + if getattr(self, "session", None) is None or self.session.closed: + self.session = new_session( + verify_ssl=self.global_config.session.downloads.verify_ssl + ) + c, t = self.config, self.tokens + if not t.access_token: + raise MissingCredentialsError( + "No Tidal access token in config -- Tidal has not been set up yet." + ) + if t.token_client_id != self.client_id: + # Tokens are bound to the OAuth client that issued them. A token + # from another client (an older streamrip, or before client_id was + # changed in the config) keeps that client's format entitlements + # and cannot be refreshed, so a fresh login is needed. + raise MissingCredentialsError( + "The saved Tidal login belongs to a different client id " + "(streamrip now logs in once per format it can download, or " + "the config changed the client). Please log in again." + ) + + try: + self.token_expiry = float(t.token_expiry) + except TypeError, ValueError: + self.token_expiry = 0.0 + self.refresh_token = t.refresh_token if self.token_expiry - time.time() < 86400: # 1 day await self._refresh_access_token() else: - await self._login_by_access_token(c.access_token, c.user_id) + await self._login_by_access_token(t.access_token, c.user_id) self.logged_in = True @@ -86,21 +255,12 @@ async def get_metadata(self, item_id: str, media_type: str) -> dict: url = f"{media_type}s/{item_id}" item = await self._api_request(url) + if media_type == "track": + self._note_hires_tags([item]) if media_type in ("playlist", "album"): - # TODO: move into new method and make concurrent - resp = await self._api_request(f"{url}/items") - tracks_left = item["numberOfTracks"] - if tracks_left > 100: - offset = 0 - while tracks_left > 0: - offset += 100 - tracks_left -= 100 - items_resp = await self._api_request( - f"{url}/items", {"offset": offset} - ) - resp["items"].extend(items_resp["items"]) - - item["tracks"] = [item["item"] for item in resp["items"]] + item["tracks"] = await self._get_tracks(url) + if media_type == "album": + await self._add_hires_format(item) elif media_type == "artist": logger.debug("filtering eps") album_resp, ep_resp = await asyncio.gather( @@ -110,7 +270,9 @@ async def get_metadata(self, item_id: str, media_type: str) -> dict: item["albums"] = album_resp["items"] item["albums"].extend(ep_resp["items"]) - elif media_type == "track": + if self.global_config.session.metadata.prefer_explicit: + item["albums"] = _dedup_duplicate_albums(item["albums"]) + elif media_type == "track" and self.global_config.session.downloads.lyrics: try: resp = await self._api_request( f"tracks/{item_id!s}/lyrics", base="https://tidal.com/v1" @@ -124,12 +286,80 @@ async def get_metadata(self, item_id: str, media_type: str) -> dict: item["lyrics"] = resp.get("lyrics") or "" else: item["lyrics"] = resp.get("subtitles") or resp.get("lyrics") or "" - except TypeError as e: + except ItemNotFoundError: + # Most tracks simply have no lyrics. That is the expected + # answer, not a problem worth reporting. + logger.debug("No lyrics available for track %s", item_id) + except ( + NonStreamableError, + TypeError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as e: + # Lyrics that should have been there but could not be + # fetched -- worth knowing about, never worth the track. Any + # request failure counts: a 401 from the lyrics endpoint used + # to escape here and abort the whole download (#959). logger.warning(f"Failed to get lyrics for {item_id}: {e}") logger.debug(item) return item + async def _get_tracks(self, url: str) -> list[dict]: + """The tracks of an album or playlist, fetched 100 at a time. + + Its items can include music videos, which can't be downloaded as + tracks; they're left out rather than failing (and being retried by + `streamrip repair`) one by one. + """ + first = await self._api_request(f"{url}/items") + pages = await asyncio.gather( + *( + self._api_request(f"{url}/items", {"offset": offset}) + for offset in range(100, first.get("totalNumberOfItems", 0), 100) + ) + ) + items = [i for page in (first, *pages) for i in page["items"]] + tracks = [i["item"] for i in items if i.get("type", "track") == "track"] + if len(tracks) < len(items): + logger.info(f"Skipping {len(items) - len(tracks)} video(s) in {url}") + self._note_hires_tags(tracks) + return tracks + + async def _add_hires_format(self, album: dict): + """Say what a hi-res album really is, for its folder name. + + The album only reports LOSSLESS; the bit depth and sample rate are in a + track's playback info. One extra request per hi-res album, and only a + label, so any failure just leaves the default. + """ + tags = (album.get("mediaMetadata") or {}).get("tags") or [] + first = next( + (t for t in album["tracks"] if str(t["id"]) not in self._no_hires), None + ) + if self.hires_lane is None or first is None or "HIRES_LOSSLESS" not in tags: + return + try: + resp = await self.hires_lane._playback_info(str(first["id"]), HIRES_TIER) + except Exception as e: + logger.debug(f"Could not read the hi-res format of {album['id']}: {e}") + return + if _AUDIO_QUALITY_TIER.get(resp.get("audioQuality"), 0) >= HIRES_TIER: + album["streamQuality"] = { + "bitDepth": resp.get("bitDepth"), + "sampleRate": resp.get("sampleRate"), + } + + def _note_hires_tags(self, tracks: list[dict]): + """Remember which tracks Tidal says have no hi-res master (like its own + apps and tidal-dl-ng, go by the `HIRES_LOSSLESS` tag), so they never cost + the hi-res client a request. Tracks without tag data are still asked. + """ + for track in tracks: + tags = (track.get("mediaMetadata") or {}).get("tags") + if tags is not None and "HIRES_LOSSLESS" not in tags: + self._no_hires.add(str(track["id"])) + async def search(self, media_type: str, query: str, limit: int = 100) -> list[dict]: """Search for a query. @@ -147,11 +377,12 @@ async def search(self, media_type: str, query: str, limit: int = 100) -> list[di } assert media_type in ("album", "track", "playlist", "video", "artist") resp = await self._api_request(f"search/{media_type}s", params=params) - if len(resp["items"]) > 1: + # A single hit is still a result: last.fm playlists search with limit=1. + if len(resp.get("items") or []) > 0: return [resp] return [] - async def get_downloadable(self, track_id: str, quality: int): + async def _playback_info(self, track_id: str, quality: int) -> dict: params = { "audioquality": QUALITY_MAP[quality], "playbackmode": "STREAM", @@ -161,11 +392,77 @@ async def get_downloadable(self, track_id: str, quality: int): f"tracks/{track_id}/playbackinfopostpaywall", params ) logger.debug(resp) + if "manifest" not in resp: + raise NonStreamableError( + resp.get("userMessage") or f"No stream available for track {track_id}" + ) + return resp + + async def _try_hires(self, track_id: str): + """A hi-res downloadable, or None to ask the lossless client instead. + + The hi-res client is only ever served hi-res or AAC 320, so a track it + has no hi-res master for -- or a failure of this optional attempt -- + goes to the lossless client, which serves FLAC and steps down from there. + """ + try: + resp = await self._playback_info(track_id, HIRES_TIER) + tier = _AUDIO_QUALITY_TIER.get(resp.get("audioQuality"), 0) + if tier >= HIRES_TIER and resp.get("manifestMimeType") == DASH_MIME: + return self._get_downloadable_from_dash(track_id, resp) + logger.debug(f"Track {track_id}: no hi-res master, using lossless") + except NonStreamableError as e: + logger.debug(f"Track {track_id}: no hi-res stream ({e}), using lossless") + except aiohttp.ClientResponseError as e: + if e.status == 429: + # _api_request already logged and paused for this; not a + # separate problem worth a second, scarier-looking warning. + logger.debug(f"Track {track_id}: still rate limited, using lossless") + else: + logger.warning(f"Track {track_id}: hi-res request failed ({e}); using lossless") + except Exception as e: + logger.warning( + f"Track {track_id}: hi-res request failed ({e}); using lossless" + ) + return None + + async def get_downloadable(self, track_id: str, quality: int): + quality = max(0, min(quality, self.max_quality)) + # Highest first: hi-res where Tidal has it, then whatever the lossless + # client is served, one step down at a time. + if ( + self.hires_lane is not None + and quality >= HIRES_TIER + and str(track_id) not in self._no_hires + ): + if (downloadable := await self.hires_lane._try_hires(track_id)) is not None: + return downloadable + + resp = await self._playback_info(track_id, quality) + + actual_quality = resp.get("audioQuality") + actual_tier = _AUDIO_QUALITY_TIER.get(actual_quality) + if actual_tier is not None and actual_tier < min(quality, LOSSLESS_TIER): + logger.warning( + f"Track {track_id}: requested {QUALITY_MAP[quality]} but Tidal " + f"only has {actual_quality} for it (most likely no lossless " + "master for this specific track)." + ) + elif actual_tier is not None and actual_tier < quality: + # Quality 3 is "best available": LOSSLESS back for it is normal. + logger.debug(f"Track {track_id}: no hi-res master, got {actual_quality}") + + # Hi-res (HI_RES_LOSSLESS) tracks are served as an MPEG-DASH manifest + # rather than the usual base64 JSON; handle it instead of silently + # falling back to a lower quality. + if resp.get("manifestMimeType") == DASH_MIME: + return self._get_downloadable_from_dash(track_id, resp) + try: manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) - except KeyError: - raise Exception(resp["userMessage"]) - except JSONDecodeError: + except JSONDecodeError, UnicodeDecodeError, ValueError: + if quality <= 0: + raise NonStreamableError(f"Could not read manifest for {track_id}") logger.warning( f"Failed to get manifest for {track_id}. Retrying with lower quality." ) @@ -177,38 +474,63 @@ async def get_downloadable(self, track_id: str, quality: int): enc_key = None return TidalDownloadable( self.session, - url=manifest["urls"][0], + url=(manifest.get("urls") or [None])[0], codec=manifest["codecs"], encryption_key=enc_key, restrictions=manifest.get("restrictions"), ) - async def get_video_file_url(self, video_id: str) -> str: - """Get the HLS video stream url. + def _get_downloadable_from_dash(self, track_id: str, resp: dict): + """Build a downloadable from a Tidal MPEG-DASH manifest. - The stream is downloaded using ffmpeg for now. - - :param video_id: - :type video_id: str - :rtype: str + From upstream PR #998. The manifest lists an init segment and a + SegmentTimeline; every media segment URL is derived from the template. """ - params = { - "videoquality": "HIGH", - "playbackmode": "STREAM", - "assetpresentation": "FULL", - } - resp = await self._api_request( - f"videos/{video_id}/playbackinfopostpaywall", params=params - ) - manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) - async with self.session.get(manifest["urls"][0]) as resp: - available_urls = await resp.json() - available_urls.encoding = "utf-8" + import xml.etree.ElementTree as ET + + dash_xml = base64.b64decode(resp["manifest"]).decode("utf-8") + ns = {"mpd": "urn:mpeg:dash:schema:mpd:2011"} + root = ET.fromstring(dash_xml) + + representation = root.find(".//mpd:Representation", ns) + if representation is None: + raise NonStreamableError(f"No Representation in DASH manifest ({track_id})") + codecs = representation.get("codecs") or "flac" + + seg_template = representation.find("mpd:SegmentTemplate", ns) + if seg_template is None: + seg_template = root.find(".//mpd:SegmentTemplate", ns) + if seg_template is None: + raise NonStreamableError( + f"No SegmentTemplate in DASH manifest ({track_id})" + ) - # Highest resolution is last - *_, last_match = STREAM_URL_REGEX.finditer(available_urls.text) + init_url = seg_template.get("initialization") + media_template = seg_template.get("media") + start_number = int(seg_template.get("startNumber", "1")) + timeline = seg_template.find("mpd:SegmentTimeline", ns) + if not init_url or not media_template or timeline is None: + raise NonStreamableError(f"Incomplete DASH manifest ({track_id})") - return last_match.group(1) + segment_count = sum( + int(s.get("r", "0")) + 1 for s in timeline.findall("mpd:S", ns) + ) + segment_urls = [ + media_template.replace("$Number$", str(n)) + for n in range(start_number, start_number + segment_count) + ] + logger.debug( + "DASH manifest for %s: codecs=%s, %d segments", + track_id, + codecs, + len(segment_urls), + ) + return TidalDASHDownloadable( + self.session, + init_url=init_url, + segment_urls=segment_urls, + codec=codecs, + ) # ---------- Login Utilities --------------- @@ -220,7 +542,7 @@ async def _login_by_access_token(self, token: str, user_id: str): :param token: access token :param user_id: To verify that the user is correct """ - headers = {"authorization": f"Bearer {token}"} # temporary + headers = {"authorization": f"Bearer {token}"} async with self.session.get( "https://api.tidal.com/v1/sessions", headers=headers, @@ -236,25 +558,12 @@ async def _login_by_access_token(self, token: str, user_id: str): c = self.config c.user_id = resp["userId"] c.country_code = resp["countryCode"] - c.access_token = token + self.tokens.access_token = token self._update_authorization_from_config() - async def _get_login_link(self) -> str: - data = { - "client_id": CLIENT_ID, - "scope": "r_usr+w_usr+w_sub", - } - resp = await self._api_post(f"{AUTH_URL}/device_authorization", data) - - if resp.get("status", 200) != 200: - raise Exception(f"Device authorization failed {resp}") - - device_code = resp["deviceCode"] - return f"https://{device_code}" - def _update_authorization_from_config(self): self.session.headers.update( - {"authorization": f"Bearer {self.config.access_token}"}, + {"authorization": f"Bearer {self.tokens.access_token}"}, ) async def _get_auth_status(self, device_code) -> tuple[int, dict[str, int | str]]: @@ -263,13 +572,13 @@ async def _get_auth_status(self, device_code) -> tuple[int, dict[str, int | str] returns (status, authentication info) """ data = { - "client_id": CLIENT_ID, + "client_id": self.client_id, "device_code": device_code, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "scope": "r_usr+w_usr+w_sub", } logger.debug("Checking with %s", data) - resp = await self._api_post(f"{AUTH_URL}/token", data, AUTH) + resp = await self._api_post(f"{AUTH_URL}/token", data, auth=True) if "status" in resp and resp["status"] != 200: if resp["status"] == 400 and resp["sub_status"] == 1002: @@ -292,28 +601,40 @@ async def _refresh_access_token(self): Requires a refresh token. """ data = { - "client_id": CLIENT_ID, + "client_id": self.client_id, "refresh_token": self.refresh_token, "grant_type": "refresh_token", "scope": "r_usr+w_usr+w_sub", } - resp = await self._api_post(f"{AUTH_URL}/token", data, AUTH) + resp = await self._api_post(f"{AUTH_URL}/token", data, auth=True) if resp.get("status", 200) != 200: - raise Exception("Refresh failed") + # The refresh token itself has lapsed or been revoked, so there is + # nothing left to refresh from and only a fresh device login will + # help. Typed so the caller can offer that instead of dying with a + # traceback. + raise AuthenticationError( + "Tidal refresh token has expired or been revoked." + ) - c = self.config - c.access_token = resp["access_token"] - c.token_expiry = resp["expires_in"] + time.time() + t = self.tokens + t.access_token = resp["access_token"] + t.token_expiry = resp["expires_in"] + time.time() + if resp.get("refresh_token"): + t.refresh_token = resp["refresh_token"] + # Persist the refreshed token, otherwise every later run refreshes again. + self.save_login() self._update_authorization_from_config() async def _get_device_code(self) -> tuple[str, str]: """Get the device code that will be used to log in on the browser.""" - if not hasattr(self, "session"): - self.session = await self.get_session() + if getattr(self, "session", None) is None or self.session.closed: + self.session = new_session( + verify_ssl=self.global_config.session.downloads.verify_ssl + ) data = { - "client_id": CLIENT_ID, + "client_id": self.client_id, "scope": "r_usr+w_usr+w_sub", } resp = await self._api_post(f"{AUTH_URL}/device_authorization", data) @@ -325,20 +646,24 @@ async def _get_device_code(self) -> tuple[str, str]: # ---------- API Request Utilities --------------- - async def _api_post(self, url, data, auth: aiohttp.BasicAuth | None = None) -> dict: + async def _api_post(self, url, data, auth: bool = False) -> dict: """Post to the Tidal API. Status not checked! :param url: :param data: - :param auth: + :param auth: send the client credentials (token endpoint) """ + headers = self.auth_headers if auth else None async with self.rate_limiter: - async with self.session.post(url, data=data, auth=auth) as resp: + async with self.session.post(url, data=data, headers=headers) as resp: return await resp.json() async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: """Handle Tidal API requests. + Connection errors, timeouts, 5xx and 429 are retried with backoff. A 429 + holds back every request, since the rate limit belongs to the account. + :param path: :type path: str :param params: @@ -348,12 +673,65 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: params = {} params["countryCode"] = self.config.country_code - params["limit"] = 100 + params.setdefault("limit", 100) - async with self.rate_limiter: - async with self.session.get(f"{base}/{path}", params=params) as resp: - if resp.status == 404: - logger.warning("TIDAL: track not found", resp) - raise NonStreamableError("TIDAL: Track not found") - resp.raise_for_status() - return await resp.json() + attempt = 0 + while True: + attempt += 1 + if (pause := self._root._retry_at - time.monotonic()) > 0: + # + jitter: every request blocked on the same 429 wakes at the + # same instant otherwise, and immediately re-trips it together. + await asyncio.sleep(pause + random.random() * 2) + + delay = 0.0 + try: + async with self.rate_limiter: + async with self.session.get( + f"{base}/{path}", params=params + ) as resp: + if resp.status == 404: + # Logged at debug, not warning: some callers ask for + # optional things (lyrics) where a 404 is the normal + # answer. Callers that do care log it themselves. + logger.debug("TIDAL: item not found (404): %s", resp.url) + raise ItemNotFoundError( + f"TIDAL: item not found: {resp.url}" + ) + if resp.status == 429: + self._pause_requests(_retry_after(resp, attempt)) + if ( + resp.status not in RETRY_STATUSES + or attempt == MAX_API_ATTEMPTS + ): + resp.raise_for_status() + return await resp.json() + if resp.status != 429: + reason = f"HTTP {resp.status}" + delay = 2**attempt + random.random() + except aiohttp.ClientSSLError: + raise + except ( + aiohttp.ClientConnectionError, + aiohttp.ClientPayloadError, + asyncio.TimeoutError, + ) as e: + if attempt == MAX_API_ATTEMPTS: + raise + reason = type(e).__name__ + delay = 2**attempt + random.random() + + if delay: + logger.warning( + f"Tidal request failed ({reason}), retrying in {delay:.0f}s" + ) + await asyncio.sleep(delay) + + def _pause_requests(self, seconds: float): + # The rate limit belongs to the account, so both lanes wait together. + root = self._root + now = time.monotonic() + if root._retry_at <= now: + logger.warning( + f"Tidal is rate limiting us (HTTP 429); pausing for {seconds:.0f}s" + ) + root._retry_at = max(root._retry_at, now + seconds) diff --git a/streamrip/config.py b/streamrip/config.py index bafa5687..08e061dc 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -5,8 +5,9 @@ import logging import os import shutil -from dataclasses import dataclass, fields +from dataclasses import dataclass, fields, is_dataclass from pathlib import Path +from typing import ClassVar import click from tomlkit.api import dumps, parse @@ -17,82 +18,60 @@ APP_DIR = click.get_app_dir("streamrip") os.makedirs(APP_DIR, exist_ok=True) DEFAULT_CONFIG_PATH = os.path.join(APP_DIR, "config.toml") -CURRENT_CONFIG_VERSION = "2.2.0" +CURRENT_CONFIG_VERSION = "2.3.3" class OutdatedConfigError(Exception): pass +# Every option is documented in config.toml, the template each user's config +# is made from. These classes only give the options their types. + + @dataclass(slots=True) class QobuzConfig: use_auth_token: bool email_or_userid: str - # This is an md5 hash of the plaintext password password_or_token: str - # Do not change app_id: str quality: int - # This will download booklet pdfs that are included with some albums download_booklets: bool - # Do not change secrets: list[str] @dataclass(slots=True) class TidalConfig: - # Do not change any of the fields below user_id: str country_code: str access_token: str refresh_token: str - # Tokens last 1 week after refresh. This is the Unix timestamp of the expiration - # time. If you haven't used streamrip in more than a week, you may have to log - # in again using `rip config --tidal` token_expiry: str - # 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 "HiFi" FLAC, 3: 24/44.1 "MQA" FLAC quality: int - # This will download videos included in Video Albums. - download_videos: bool + hires_client: bool = False + client_id: str = "" + client_secret: str = "" + token_client_id: str = "" + # The hi-res client's own login, next to the tokens above. + hires_access_token: str = "" + hires_refresh_token: str = "" + hires_token_expiry: str = "" + hires_token_client_id: str = "" @dataclass(slots=True) class DeezerConfig: - # An authentication cookie that allows streamrip to use your Deezer account - # See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie - # for instructions on how to find this arl: str - # 0, 1, or 2 - # This only applies to paid Deezer subscriptions. Those using deezloader - # are automatically limited to quality = 1 quality: int - # If the target quality is not available, fallback to best quality available lower_quality_if_not_available: bool - # This allows for free 320kbps MP3 downloads from Deezer - # If an arl is provided, deezloader is never used - use_deezloader: bool - # This warns you when the paid deezer account is not logged in and rip falls - # back to deezloader, which is unreliable - deezloader_warnings: bool @dataclass(slots=True) class SoundcloudConfig: - # This changes periodically, so it needs to be updated client_id: str app_version: str - # Only 0 is available for now - quality: int - - -@dataclass(slots=True) -class YoutubeConfig: - # The path to download the videos to - video_downloads_folder: str - # Only 0 is available for now - quality: int - # Download the video along with the audio - download_videos: bool + # SoundCloud streams in one quality; nothing to configure. + quality: ClassVar[int] = 0 @dataclass(slots=True) @@ -106,121 +85,67 @@ class DatabaseConfig: @dataclass(slots=True) class ConversionConfig: enabled: bool - # FLAC, ALAC, OPUS, MP3, VORBIS, or AAC codec: str - # In Hz. Tracks are downsampled if their sampling rate is greater than this. - # Value of 48000 is recommended to maximize quality and minimize space sampling_rate: int - # Only 16 and 24 are available. It is only applied when the bit depth is higher - # than this value. bit_depth: int - # Only applicable for lossy codecs lossy_bitrate: int @dataclass(slots=True) -class QobuzDiscographyFilterConfig: - # Remove Collectors Editions, live recordings, etc. +class ArtistFilterConfig: extras: bool - # Picks the highest quality out of albums with identical titles. repeats: bool - # Remove EPs and Singles non_albums: bool - # Remove albums whose artist is not the one requested features: bool - # Skip non studio albums - non_studio_albums: bool - # Only download remastered albums non_remaster: bool @dataclass(slots=True) class ArtworkConfig: - # Write the image to the audio file embed: bool - # The size of the artwork to embed. Options: thumbnail, small, large, original. - # "original" images can be up to 30MB, and may fail embedding. - # Using "large" is recommended. embed_size: str - # Both of these options limit the size of the embedded artwork. If their values - # are larger than the actual dimensions of the image, they will be ignored. - # If either value is -1, the image is left untouched. embed_max_width: int - # Save the cover image at the highest quality as a seperate jpg file save_artwork: bool - # If artwork is saved, downscale it to these dimensions, or ignore if -1 saved_max_width: int @dataclass(slots=True) class MetadataConfig: - # Sets the value of the 'ALBUM' field in the metadata to the playlist's name. - # This is useful if your music library software organizes tracks based on album name. set_playlist_to_album: bool - # If part of a playlist, sets the `tracknumber` field in the metadata to the track's - # position in the playlist instead of its position in its album renumber_playlist_tracks: bool - # The following metadata tags won't be applied - # See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info exclude: list[str] + prefer_explicit: bool = True @dataclass(slots=True) class FilepathsConfig: - # Create folders for single tracks within the downloads directory using the folder_format - # template add_singles_to_folder: bool - # Available keys: "albumartist", "title", "year", "bit_depth", "sampling_rate", - # "container", "id", and "albumcomposer" folder_format: str - # Available keys: "tracknumber", "artist", "albumartist", "composer", "title", - # and "albumcomposer" track_format: str - # Only allow printable ASCII characters in filenames. restrict_characters: bool - # Truncate the filename if it is greater than 120 characters - # Setting this to false may cause downloads to fail on some systems truncate_to: int @dataclass(slots=True) class DownloadsConfig: - # Folder where tracks are downloaded to folder: str - # Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal' etc. source_subdirectories: bool - # Put tracks in an album with 2 or more discs into a subfolder named `Disc N` disc_subdirectories: bool - # Download (and convert) tracks all at once, instead of sequentially. - # If you are converting the tracks, or have fast internet, this will - # substantially improve processing speed. - concurrency: bool - # The maximum number of tracks to download at once - # If you have very fast internet, you will benefit from a higher value, - # A value that is too high for your bandwidth may cause slowdowns max_connections: int requests_per_minute: int - # Verify SSL certificates for API connections - # Set to false if you encounter SSL certificate verification errors (not recommended) verify_ssl: bool + lyrics: bool = True @dataclass(slots=True) class LastFmConfig: - # The source on which to search for the tracks. source: str - # If no results were found with the primary source, the item is searched for - # on this one. fallback_source: str @dataclass(slots=True) class CliConfig: - # Print "Downloading {Album name}" etc. to screen - text_output: bool - # Show resolve, download progress bars progress_bars: bool - # The maximum number of search results to show in the interactive menu max_search_results: int @@ -234,10 +159,6 @@ class MiscConfig: DEFAULT_DOWNLOADS_FOLDER = os.path.join(HOME, "StreamripDownloads") DEFAULT_DOWNLOADS_DB_PATH = os.path.join(APP_DIR, "downloads.db") DEFAULT_FAILED_DOWNLOADS_DB_PATH = os.path.join(APP_DIR, "failed_downloads.db") -DEFAULT_YOUTUBE_VIDEO_DOWNLOADS_FOLDER = os.path.join( - DEFAULT_DOWNLOADS_FOLDER, - "YouTubeVideos", -) BLANK_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.toml") assert os.path.isfile(BLANK_CONFIG_PATH), "Template config not found" @@ -245,24 +166,20 @@ class MiscConfig: @dataclass(slots=True) class ConfigData: toml: TOMLDocument + # One field per [section] of config.toml. downloads: DownloadsConfig - qobuz: QobuzConfig tidal: TidalConfig deezer: DeezerConfig soundcloud: SoundcloudConfig - youtube: YoutubeConfig lastfm: LastFmConfig - filepaths: FilepathsConfig artwork: ArtworkConfig metadata: MetadataConfig - qobuz_filters: QobuzDiscographyFilterConfig - + artist_filters: ArtistFilterConfig cli: CliConfig database: DatabaseConfig conversion: ConversionConfig - misc: MiscConfig _modified: bool = False @@ -275,41 +192,8 @@ def from_toml(cls, toml_str: str): raise OutdatedConfigError( f"Need to update config from {v} to {CURRENT_CONFIG_VERSION}", ) - - downloads = DownloadsConfig(**toml["downloads"]) # type: ignore - qobuz = QobuzConfig(**toml["qobuz"]) # type: ignore - tidal = TidalConfig(**toml["tidal"]) # type: ignore - deezer = DeezerConfig(**toml["deezer"]) # type: ignore - soundcloud = SoundcloudConfig(**toml["soundcloud"]) # type: ignore - youtube = YoutubeConfig(**toml["youtube"]) # type: ignore - lastfm = LastFmConfig(**toml["lastfm"]) # type: ignore - artwork = ArtworkConfig(**toml["artwork"]) # type: ignore - filepaths = FilepathsConfig(**toml["filepaths"]) # type: ignore - metadata = MetadataConfig(**toml["metadata"]) # type: ignore - qobuz_filters = QobuzDiscographyFilterConfig(**toml["qobuz_filters"]) # type: ignore - cli = CliConfig(**toml["cli"]) # type: ignore - database = DatabaseConfig(**toml["database"]) # type: ignore - conversion = ConversionConfig(**toml["conversion"]) # type: ignore - misc = MiscConfig(**toml["misc"]) # type: ignore - - return cls( - toml=toml, - downloads=downloads, - qobuz=qobuz, - tidal=tidal, - deezer=deezer, - soundcloud=soundcloud, - youtube=youtube, - lastfm=lastfm, - artwork=artwork, - filepaths=filepaths, - metadata=metadata, - qobuz_filters=qobuz_filters, - cli=cli, - database=database, - conversion=conversion, - misc=misc, - ) + sections = {f.name: f.type(**toml[f.name]) for f in _section_fields()} # type: ignore + return cls(toml=toml, **sections) @classmethod def defaults(cls): @@ -324,35 +208,20 @@ def modified(self): return self._modified def update_toml(self): - update_toml_section_from_config(self.toml["downloads"], self.downloads) - update_toml_section_from_config(self.toml["qobuz"], self.qobuz) - update_toml_section_from_config(self.toml["tidal"], self.tidal) - update_toml_section_from_config(self.toml["deezer"], self.deezer) - update_toml_section_from_config(self.toml["soundcloud"], self.soundcloud) - update_toml_section_from_config(self.toml["youtube"], self.youtube) - update_toml_section_from_config(self.toml["lastfm"], self.lastfm) - update_toml_section_from_config(self.toml["artwork"], self.artwork) - update_toml_section_from_config(self.toml["filepaths"], self.filepaths) - update_toml_section_from_config(self.toml["metadata"], self.metadata) - update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters) - update_toml_section_from_config(self.toml["cli"], self.cli) - update_toml_section_from_config(self.toml["database"], self.database) - update_toml_section_from_config(self.toml["conversion"], self.conversion) + for f in _section_fields(): + update_toml_section_from_config(self.toml[f.name], getattr(self, f.name)) def get_source( self, source: str, ) -> QobuzConfig | DeezerConfig | SoundcloudConfig | TidalConfig: - d = { - "qobuz": self.qobuz, - "deezer": self.deezer, - "soundcloud": self.soundcloud, - "tidal": self.tidal, - } - res = d.get(source) - if res is None: + if source not in ("qobuz", "tidal", "deezer", "soundcloud"): raise Exception(f"Invalid source {source}") - return res + return getattr(self, source) + + +def _section_fields(): + return [f for f in fields(ConfigData) if is_dataclass(f.type)] def update_toml_section_from_config(toml_section, config): @@ -386,9 +255,10 @@ def _update_file(old_path: str, new_path: str): toml_set_user_defaults(new_toml) with open(old_path) as old_conf: - old_toml = parse(old_conf.read()) + old = parse(old_conf.read()).unwrap() - update_config(old_toml, new_toml) + _carry_over_merged_options(old) + update_config(old, new_toml) with open(old_path, "w") as f: f.write(dumps(new_toml)) @@ -425,7 +295,6 @@ def toml_set_user_defaults(toml: TOMLDocument): toml["downloads"]["folder"] = DEFAULT_DOWNLOADS_FOLDER # type: ignore toml["database"]["downloads_path"] = DEFAULT_DOWNLOADS_DB_PATH # type: ignore toml["database"]["failed_downloads_path"] = DEFAULT_FAILED_DOWNLOADS_DB_PATH # type: ignore - toml["youtube"]["video_downloads_folder"] = DEFAULT_YOUTUBE_VIDEO_DOWNLOADS_FOLDER # type: ignore def _get_dict_keys_r(d: dict) -> set[tuple]: @@ -459,6 +328,41 @@ def _nested_set(dictionary, *keys, val): final[keys[-1]] = val +def _carry_over_merged_options(old: dict): + """Rewrite options that were merged into others, or renamed, so that + update_config() carries their settings over instead of dropping them. + """ + downloads = old.get("downloads", {}) + # `concurrency = false` meant one download at a time. + if downloads.get("concurrency") is False: + downloads["max_connections"] = 1 + + if (filters := old.pop("qobuz_filters", None)) is not None: + # non_studio_albums was extras plus various-artists compilations, + # which extras now includes. + if filters.get("non_studio_albums"): + filters["extras"] = True + old["artist_filters"] = filters + + tidal = old.get("tidal", {}) + # Before the hi-res client got a login of its own, `hires_client = true` + # replaced the default client and its tokens were kept in the same fields. + if ( + tidal.get("hires_client") + and not tidal.get("client_id") + and tidal.get("access_token") + and not tidal.get("hires_access_token") + ): + for name in ( + "access_token", + "refresh_token", + "token_expiry", + "token_client_id", + ): + tidal["hires_" + name] = tidal.get(name, "") + tidal[name] = "" + + def update_config(old_with_data: dict, new_without_data: dict): """Used to update config when a new config version is detected. diff --git a/streamrip/config.toml b/streamrip/config.toml index 029115bd..0f8ab7ea 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -5,29 +5,34 @@ folder = "" source_subdirectories = false # Put tracks in an album with 2 or more discs into a subfolder named `Disc N` disc_subdirectories = true -# Download (and convert) tracks all at once, instead of sequentially. -# If you are converting the tracks, or have fast internet, this will -# substantially improve processing speed. -concurrency = true -# The maximum number of tracks to download at once -# If you have very fast internet, you will benefit from a higher value, -# A value that is too high for your bandwidth may cause slowdowns -# Set to -1 for no limit +# How many tracks to download at once. 1 downloads them one after another, +# -1 means no limit. More than your connection can carry only slows each +# download down. max_connections = 6 # Max number of API requests per source to handle per minute -# Set to -1 for no limit -requests_per_minute = 60 +# Set to -1 for no limit. Downloading many albums at once (e.g. an artist's +# full discography) can burst past a higher limit and get 429 "Too Many +# Requests" errors from the streaming service; lower this further if that +# still happens. +requests_per_minute = 35 # Verify SSL certificates for API connections # Set to false if you encounter SSL certificate verification errors (not recommended) verify_ssl = true +# Download and embed lyrics (currently supported for Deezer and Tidal) +lyrics = true [qobuz] # 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>=96 -quality = 3 +quality = 4 # This will download booklet pdfs that are included with some albums download_booklets = true # Authenticate to Qobuz using auth token? Value can be true/false only +# Qobuz moved its web login behind a captcha, so token login is recommended: +# log in at play.qobuz.com, open DevTools -> Network, find the user/login +# request and copy user.id and user_auth_token from its response. +# streamrip can capture these for you instead: it'll offer to open a +# browser and log in normally the next time login is needed. use_auth_token = false # Enter your userid if the above use_auth_token is set to true, else enter your email email_or_userid = "" @@ -39,10 +44,21 @@ app_id = "" secrets = [] [tidal] -# 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 "HiFi" FLAC, 3: 24/44.1 "MQA" FLAC +# 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 FLAC, 3: best available +# (24-bit FLAC where the client below is allowed to stream it). +# Default is 3: hi-res wherever Tidal has a hi-res master. Set it to 2 for +# 16/44.1 FLAC only. quality = 3 -# This will download videos included in Video Albums. -download_videos = true +# Tidal serves each of its OAuth clients other formats. The default one gets +# FLAC 16/44.1 for every lossless release but never hi-res; the hi-res one gets +# 24-bit FLAC (up to 192 kHz) for hi-res releases but only AAC 320 for the rest. +# With this on (and quality = 3) each track is asked of the hi-res client first +# and of the default one when Tidal has no hi-res master, so you get the best +# there is. It means a second login. Off: the default client only, FLAC 16/44.1. +hires_client = true +# Override the OAuth client id and secret (leave empty for the built-in one) +client_id = "" +client_secret = "" # Do not change any of the fields below user_id = "" @@ -50,44 +66,33 @@ country_code = "" access_token = "" refresh_token = "" # Tokens last 1 week after refresh. This is the Unix timestamp of the expiration -# time. If you haven't used streamrip in more than a week, you may have to log -# in again using `rip config --tidal` +# time. If you haven't used streamrip in more than a week, you may be asked to +# log in again. token_expiry = "" +# The client id the tokens above were issued to +token_client_id = "" +# The same four for the hi-res client (see hires_client) +hires_access_token = "" +hires_refresh_token = "" +hires_token_expiry = "" +hires_token_client_id = "" [deezer] -# 0, 1, or 2 -# This only applies to paid Deezer subscriptions. Those using deezloader -# are automatically limited to quality = 1 +# 0: 128kbps MP3, 1: 320kbps MP3, 2: FLAC. Limited to what the account's +# subscription allows. quality = 2 # If the target quality is not available, fallback to best quality available -# 0 = MP3_128, 1 = MP3_320, 2 = FLAC lower_quality_if_not_available = true # An authentication cookie that allows streamrip to use your Deezer account # See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie # for instructions on how to find this arl = "" -# This allows for free 320kbps MP3 downloads from Deezer -# If an arl is provided, deezloader is never used -use_deezloader = true -# This warns you when the paid deezer account is not logged in and rip falls -# back to deezloader, which is unreliable -deezloader_warnings = true [soundcloud] -# Only 0 is available for now -quality = 0 -# This changes periodically, so it needs to be updated +# Found and kept up to date by streamrip. Leave these alone. client_id = "" app_version = "" -[youtube] -# Only 0 is available for now -quality = 0 -# Download the video along with the audio -download_videos = false -# The path to download the videos to -video_downloads_folder = "" - [database] # Create a database that contains all the track IDs downloaded so far # Any time a track logged in the database is requested, it is skipped @@ -95,7 +100,7 @@ video_downloads_folder = "" downloads_enabled = true # Path to the downloads database downloads_path = "" -# If a download fails, the item ID is stored here. Then, `rip repair` can be +# If a download fails, the item ID is stored here. Then, `streamrip repair` can be # called to retry the downloads failed_downloads_enabled = true failed_downloads_path = "" @@ -103,7 +108,7 @@ failed_downloads_path = "" # Convert tracks to a codec after downloading them. [conversion] enabled = false -# FLAC, ALAC, OPUS, MP3, VORBIS, or AAC +# FLAC, ALAC, AIFF, OPUS, MP3, VORBIS, or AAC codec = "ALAC" # In Hz. Tracks are downsampled if their sampling rate is greater than this. # Value of 48000 is recommended to maximize quality and minimize space @@ -114,19 +119,20 @@ bit_depth = 24 # Only applicable for lossy codecs lossy_bitrate = 320 -# Filter a Qobuz artist's discography. Set to 'true' to turn on a filter. -# This will also be applied to other sources, but is not guaranteed to work correctly -[qobuz_filters] -# Remove Collectors Editions, live recordings, etc. +# Filters for downloading an artist's discography, from any source. +# Set one to true to turn it on. +[artist_filters] +# Skip special editions (deluxe, anniversary, collector's, expanded), live +# albums, demos, remixes and various-artists compilations extras = false -# Picks the highest quality out of albums with identical titles. +# Of albums with the same title (ignoring anything in brackets), only +# download the best quality one repeats = false -# Remove EPs and Singles +# Skip singles (releases with only one track) non_albums = false -# Remove albums whose artist is not the one requested +# Skip albums credited to someone else, such as ones the artist only +# features on features = false -# Skip non studio albums -non_studio_albums = false # Only download remastered albums non_remaster = false @@ -157,6 +163,11 @@ renumber_playlist_tracks = true # The following metadata tags won't be applied # See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info exclude = [] +# When an album or playlist has both a clean and an explicit copy of the same +# track, skip the clean one and only download the explicit copy. On by +# default; it does not cost extra API calls, only a later start (every track +# is resolved before any of them download, instead of as each one resolves). +prefer_explicit = true # Changes the folder and file names generated by streamrip. [filepaths] @@ -164,15 +175,15 @@ exclude = [] # template add_singles_to_folder = false # Available keys: "albumartist", "title", "year", "bit_depth", "sampling_rate", -# "id", and "albumcomposer" +# "container", "id", "albumcomposer", "tracktotal", and "version" (edition) folder_format = "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]" -# Available keys: "id", "tracknumber", "artist", "albumartist", "composer", "title", -# and "albumcomposer", "explicit" +# Available keys: "id", "tracknumber", "discnumber", "artist", "album", +# "albumartist", "composer", "title", "albumcomposer", "explicit" track_format = "{tracknumber:02}. {artist} - {title}{explicit}" # Only allow printable ASCII characters in filenames. restrict_characters = false -# Truncate the filename if it is greater than this number of characters -# Setting this to false may cause downloads to fail on some systems +# Shorten track file names to this many characters (0: no limit). Names are +# always shortened as far as the file system needs, whatever this is set to. truncate_to = 120 # Last.fm playlists are downloaded by searching for the titles of the tracks @@ -184,8 +195,6 @@ source = "qobuz" fallback_source = "" [cli] -# Print "Downloading {Album name}" etc. to screen -text_output = true # Show resolve, download progress bars progress_bars = true # The maximum number of search results to show in the interactive menu @@ -193,6 +202,6 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.2.0" +version = "2.3.3" # Print a message if a new version of streamrip is available check_for_updates = true diff --git a/streamrip/console.py b/streamrip/console.py index a9463afd..a99c8692 100644 --- a/streamrip/console.py +++ b/streamrip/console.py @@ -1,3 +1,9 @@ +import os + from rich.console import Console -console = Console() +# Rich's Console.size prefers COLUMNS/LINES env vars over the terminal's +# actual current size, and those don't update when a panel is resized -- +# stripping them keeps it reading the real size instead of a stale one. +_environ = {k: v for k, v in os.environ.items() if k not in ("COLUMNS", "LINES")} +console = Console(_environ=_environ) diff --git a/streamrip/converter.py b/streamrip/converter.py index aa4aa2aa..808180e9 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -1,13 +1,18 @@ """Wrapper classes over FFMPEG.""" import asyncio +import base64 +import functools import logging import os import shutil +import subprocess +import uuid from tempfile import gettempdir -from typing import Final, Optional +from typing import Final from .exceptions import ConversionError +from .utils.ffmpeg_utils import find_ffmpeg logger = logging.getLogger("streamrip") @@ -17,161 +22,161 @@ class Converter: """Base class for audio codecs.""" - codec_name: str codec_lib: str container: str lossless: bool = False default_ffmpeg_arg: str = "" + # Subclasses set this to False when the muxer doesn't accept -c:v copy. + # Art is then embedded post-conversion via mutagen instead. + _ffmpeg_supports_art: bool = True def __init__( self, filename: str, - ffmpeg_arg: Optional[str] = None, - sampling_rate: Optional[int] = None, - bit_depth: Optional[int] = None, - copy_art: bool = True, + ffmpeg_arg: str | None = None, + sampling_rate: int | None = None, + bit_depth: int | None = None, remove_source: bool = False, - show_progress: bool = False, ): - """Create a Converter object. - - :param filename: - :type filename: str - :param ffmpeg_arg: The codec ffmpeg argument (defaults to an "optimal value") - :type ffmpeg_arg: Optional[str] - :param sampling_rate: This value is ignored if a lossy codec is detected - :type sampling_rate: Optional[int] - :param bit_depth: This value is ignored if a lossy codec is detected - :type bit_depth: Optional[int] - :param copy_art: Embed the cover art (if found) into the encoded file - :type copy_art: bool - :param remove_source: Remove the source file after conversion. - :type remove_source: bool + """Convert `filename` to this codec, next to the original. + + `ffmpeg_arg` sets a lossy codec's quality (see get_quality_arg); + `sampling_rate` and `bit_depth` cap a lossless conversion's. """ - if shutil.which("ffmpeg") is None: + self.ffmpeg_path = find_ffmpeg() + if self.ffmpeg_path is None: raise Exception( - "Could not find FFMPEG executable. Install it to convert audio files.", + "Could not find FFmpeg. Install it, or install streamrip's " + "ffmpeg extra, to convert audio files.", ) self.filename = filename self.final_fn = f"{os.path.splitext(filename)[0]}.{self.container}" - self.tempfile = os.path.join(gettempdir(), os.path.basename(self.final_fn)) + # Unique per conversion: tracks from different albums often share a + # file name ("01. Intro"), and concurrent conversions used to + # overwrite each other's temp file. + self.tempfile = os.path.join( + gettempdir(), + f"__streamrip_{uuid.uuid4().hex}_{os.path.basename(self.final_fn)}", + ) self.remove_source = remove_source self.sampling_rate = sampling_rate self.bit_depth = bit_depth - self.copy_art = copy_art - self.show_progress = show_progress - - if ffmpeg_arg is None: - logger.debug("No arguments provided. Codec defaults will be used") - self.ffmpeg_arg = self.default_ffmpeg_arg - else: - self.ffmpeg_arg = ffmpeg_arg - self._is_command_valid() - - logger.debug("FFmpeg codec extra argument: %s", self.ffmpeg_arg) - - async def convert(self, custom_fn: Optional[str] = None): - """Convert the file. - - :param custom_fn: Custom output filename (defaults to the original - name with a replaced container) - :type custom_fn: Optional[str] - """ - if custom_fn: - self.final_fn = custom_fn - - self.command = self._gen_command() - logger.debug("Generated conversion command: %s", self.command) - + self.ffmpeg_arg = self.default_ffmpeg_arg if ffmpeg_arg is None else ffmpeg_arg + + async def convert(self): + # Read cover art from the source before FFmpeg runs and before any + # potential source deletion, so it's available for post-conversion + # embedding even when remove_source=True. + cover_data: tuple[bytes, str] | None = None + if not self._ffmpeg_supports_art: + cover_data = await asyncio.to_thread(self._read_source_cover) + + command = self._gen_command() + logger.debug("Converting: %s", command) process = await asyncio.create_subprocess_exec( - *self.command, + *command, + stdin=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) - out, err = await process.communicate() - if process.returncode == 0 and os.path.isfile(self.tempfile): - if self.remove_source: - os.remove(self.filename) - logger.debug("Source removed: %s", self.filename) - - shutil.move(self.tempfile, self.final_fn) - logger.debug("Moved: %s -> %s", self.tempfile, self.final_fn) - else: - raise ConversionError(f"FFmpeg output:\n{out, err}") - - def _gen_command(self): - command = [ - "ffmpeg", - "-i", - self.filename, - ] - - if logger.getEffectiveLevel() != logging.DEBUG: - command.extend(("-loglevel", "panic")) - - command.extend(("-c:a", self.codec_lib)) - - if self.show_progress: - command.append("-stats") - - if self.copy_art: - command.extend(["-c:v", "copy"]) - - if self.ffmpeg_arg: - command.extend(self.ffmpeg_arg.split()) - - if self.lossless: - aformat = [] - - if isinstance(self.sampling_rate, int): - sample_rates = "|".join( - str(rate) for rate in SAMPLING_RATES if rate <= self.sampling_rate - ) - aformat.append(f"sample_rates={sample_rates}") - elif self.sampling_rate is not None: - raise TypeError( - f"Sampling rate must be int, not {type(self.sampling_rate)}" - ) - - if isinstance(self.bit_depth, int): - bit_depths = ["s16p", "s16"] - - if self.bit_depth in (24, 32): - bit_depths.extend(["s32p", "s32"]) - elif self.bit_depth != 16: - raise ValueError("Bit depth must be 16, 24, or 32") - - sample_fmts = "|".join(bit_depths) - aformat.append(f"sample_fmts={sample_fmts}") - elif self.bit_depth is not None: - raise TypeError(f"Bit depth must be int, not {type(self.bit_depth)}") - - if aformat: - aformat_params = ":".join(aformat) - command.extend(["-af", f"aformat={aformat_params}"]) - - # automatically overwrite - command.extend(["-y", self.tempfile]) - - logger.debug(command) - - return command - - def _is_command_valid(self): - # TODO: add error handling for lossy codecs - if self.ffmpeg_arg is not None and self.lossless: - logger.debug( - "Lossless codecs don't support extra arguments; " - "the extra argument will be ignored", + _, err = await process.communicate() + if process.returncode != 0 or not os.path.isfile(self.tempfile): + if os.path.exists(self.tempfile): + os.remove(self.tempfile) + raise ConversionError( + f"ffmpeg failed (exit {process.returncode}): " + f"{err.decode(errors='replace')[-300:]}" ) - self.ffmpeg_arg = self.default_ffmpeg_arg - return + + if self.remove_source: + os.remove(self.filename) + shutil.move(self.tempfile, self.final_fn) + if cover_data is not None: + await asyncio.to_thread(self._embed_cover_art, *cover_data) + + def _read_source_cover(self) -> tuple[bytes, str] | None: + """Read cover art from the source file. Returns (data, mime) or None.""" + from mutagen.flac import FLAC + from mutagen.id3 import ID3 + + src_ext = os.path.splitext(self.filename)[1].lower() + try: + if src_ext == ".flac": + src = FLAC(self.filename) + if src.pictures: + p = src.pictures[0] + return p.data, p.mime + elif src_ext == ".mp3": + tags = ID3(self.filename) + apic_list = tags.getall("APIC") + if apic_list: + return apic_list[0].data, apic_list[0].mime + except Exception as e: + logger.debug("Could not read cover art from source: %s", e) + return None + + def _embed_cover_art(self, cover_data: bytes, mime: str) -> None: + """Embed cover art into the converted OGG/OPUS file via METADATA_BLOCK_PICTURE.""" + from mutagen.flac import Picture + + pic = Picture() + pic.type = 3 # front cover + pic.mime = mime + pic.data = cover_data + encoded = base64.b64encode(pic.write()).decode("ascii") + + out_ext = os.path.splitext(self.final_fn)[1].lower() + try: + if out_ext == ".ogg": + from mutagen.oggvorbis import OggVorbis + + audio = OggVorbis(self.final_fn) + elif out_ext == ".opus": + from mutagen.oggopus import OggOpus + + audio = OggOpus(self.final_fn) + else: + return + audio["metadata_block_picture"] = [encoded] + audio.save() + logger.debug("Embedded cover art via mutagen into %s", self.final_fn) + except Exception as e: + logger.warning("Could not embed cover art into %s: %s", self.final_fn, e) + + def _gen_command(self) -> list[str]: + # Only errors: they're captured for ConversionError, and anything more + # ffmpeg prints would garble the progress bars. + command = [self.ffmpeg_path, "-i", self.filename, "-loglevel", "error"] + command += ["-c:a", self.get_codec_lib()] + # The cover art is a video stream, copied where the container takes one. + command += ["-c:v", "copy"] if self._ffmpeg_supports_art else ["-vn"] + command += self.ffmpeg_arg.split() + + aformat = [] + if self.lossless and self.sampling_rate is not None: + rates = (str(r) for r in sorted(SAMPLING_RATES) if r <= self.sampling_rate) + aformat.append(f"sample_rates={'|'.join(rates)}") + if self.lossless and self.bit_depth is not None: + if self.bit_depth not in (16, 24, 32): + raise ValueError("Bit depth must be 16, 24, or 32") + formats = ["s16p", "s16"] + (["s32p", "s32"] if self.bit_depth > 16 else []) + aformat.append(f"sample_fmts={'|'.join(formats)}") + if aformat: + command += ["-af", f"aformat={':'.join(aformat)}"] + + return [*command, "-y", self.tempfile] + + def get_codec_lib(self) -> str: + return self.codec_lib + + @classmethod + def get_quality_arg(cls, _: int) -> str: + return cls.default_ffmpeg_arg class FLAC(Converter): """Class for FLAC converter.""" - codec_name = "flac" codec_lib = "flac" container = "flac" lossless = True @@ -200,19 +205,18 @@ class LAME(Converter): 65: "-q:a 9", } - codec_name = "lame" codec_lib = "libmp3lame" container = "mp3" default_ffmpeg_arg = "-q:a 0" # V0 - def get_quality_arg(self, rate): - return self._bitrate_map[rate] + @classmethod + def get_quality_arg(cls, rate): + return cls._bitrate_map.get(rate, f"-b:a {rate}k") class ALAC(Converter): """Class for ALAC converter.""" - codec_name = "alac" codec_lib = "alac" container = "m4a" lossless = True @@ -227,13 +231,15 @@ class Vorbis(Converter): https://trac.ffmpeg.org/wiki/TheoraVorbisEncodingGuide """ - codec_name = "vorbis" codec_lib = "libvorbis" container = "ogg" + # The OGG muxer doesn't support -c:v copy; art is embedded via mutagen instead. + _ffmpeg_supports_art = False default_ffmpeg_arg = "-q:a 6" # 160, aka the "high" quality profile from Spotify - def get_quality_arg(self, rate: int) -> str: - arg = "qscale:a %d" + @classmethod + def get_quality_arg(cls, rate: int) -> str: + arg = "-qscale:a %d" if rate <= 128: return arg % (rate / 16 - 4) if rate <= 256: @@ -251,17 +257,23 @@ class OPUS(Converter): http://ffmpeg.org/ffmpeg-codecs.html#libopus-1 """ - codec_name = "opus" codec_lib = "libopus" container = "opus" + # The Opus muxer doesn't support -c:v copy; art is embedded via mutagen instead. + _ffmpeg_supports_art = False default_ffmpeg_arg = "-b:a 128k" # Transparent - def get_quality_arg(self, _: int) -> str: - return "" + @classmethod + def get_quality_arg(cls, rate: int) -> str: + return f"-b:a {rate}k" class AAC(Converter): - """Class for libfdk_aac converter. + """Class for AAC converter. + + Uses libfdk_aac when ffmpeg was built with it, and ffmpeg's native aac + encoder otherwise; most ffmpeg builds lack libfdk_aac, which made every + AAC conversion fail (upstream #1010). Default ffmpeg_arg: `-b:a 256k`. @@ -269,13 +281,48 @@ class AAC(Converter): https://trac.ffmpeg.org/wiki/Encode/AAC """ - codec_name = "aac" codec_lib = "libfdk_aac" container = "m4a" default_ffmpeg_arg = "-b:a 256k" - def get_quality_arg(self, _: int) -> str: - return "" + def get_codec_lib(self) -> str: + return "libfdk_aac" if _ffmpeg_has_encoder("libfdk_aac") else "aac" + + @classmethod + def get_quality_arg(cls, rate: int) -> str: + return f"-b:a {rate}k" + + +class AIFF(Converter): + """Class for AIFF converter (uncompressed PCM, upstream PR #1006).""" + + codec_lib = "pcm_s24be" + container = "aiff" + lossless = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Keep 16-bit sources 16-bit instead of padding them to 24. + if self.bit_depth == 16: + self.codec_lib = "pcm_s16be" + + +@functools.cache +def _ffmpeg_has_encoder(name: str) -> bool: + ffmpeg_path = find_ffmpeg() + if ffmpeg_path is None: + return False + try: + result = subprocess.run( + [ffmpeg_path, "-hide_banner", "-encoders"], + capture_output=True, + text=True, + timeout=10, + stdin=subprocess.DEVNULL, + ) + except OSError, subprocess.SubprocessError: + return False + return name in result.stdout def get(codec: str) -> type[Converter]: @@ -288,5 +335,6 @@ def get(codec: str) -> type[Converter]: "VORBIS": Vorbis, "AAC": AAC, "M4A": AAC, + "AIFF": AIFF, } return converter_classes[codec.upper()] diff --git a/streamrip/db.py b/streamrip/db.py index 94085e11..a8ccd619 100644 --- a/streamrip/db.py +++ b/streamrip/db.py @@ -3,55 +3,32 @@ import logging import os import sqlite3 -from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Final logger = logging.getLogger("streamrip") -class DatabaseInterface(ABC): - @abstractmethod - def create(self): - pass - - @abstractmethod - def contains(self, **items) -> bool: - pass - - @abstractmethod - def add(self, kvs): - pass - - @abstractmethod - def remove(self, kvs): - pass - - @abstractmethod - def all(self) -> list: - pass - - -class Dummy(DatabaseInterface): - """This exists as a mock to use in case databases are disabled.""" +class Dummy: + """Stands in for a database that's disabled in the config.""" - def create(self): - pass - - def contains(self, **_): + def contains(self, **_) -> bool: return False def add(self, *_): pass - def remove(self, *_): + def remove(self, **_): pass - def all(self): + def clear(self) -> int: + return 0 + + def all(self) -> list: return [] -class DatabaseBase(DatabaseInterface): +class DatabaseBase: """A wrapper for an sqlite database.""" structure: dict @@ -84,10 +61,6 @@ def create(self): conn.execute(command) - def keys(self): - """Get the column names of the table.""" - return self.structure.keys() - def contains(self, **items) -> bool: """Check whether items matches an entry in the table. @@ -95,9 +68,9 @@ def contains(self, **items) -> bool: :rtype: bool """ allowed_keys = set(self.structure.keys()) - assert all( - key in allowed_keys for key in items.keys() - ), f"Invalid key. Valid keys: {allowed_keys}" + assert all(key in allowed_keys for key in items.keys()), ( + f"Invalid key. Valid keys: {allowed_keys}" + ) items = {k: str(v) for k, v in items.items()} @@ -132,12 +105,7 @@ def add(self, items: tuple[str]): logger.debug(e) def remove(self, **items): - """Remove items from a table. - - Warning: NOT TESTED! - - :param items: - """ + """Delete the rows that match every column-name=value given.""" conditions = " AND ".join(f"{key}=?" for key in items.keys()) command = f"DELETE FROM {self.name} WHERE {conditions}" @@ -145,18 +113,18 @@ def remove(self, **items): logger.debug(command) conn.execute(command, tuple(items.values())) + def clear(self) -> int: + """Delete every row of the table. Returns how many there were.""" + with sqlite3.connect(self.path) as conn: + count = conn.execute(f"SELECT COUNT(*) FROM {self.name}").fetchone()[0] + conn.execute(f"DELETE FROM {self.name}") + return count + def all(self): """Iterate through the rows of the table.""" with sqlite3.connect(self.path) as conn: return list(conn.execute(f"SELECT * FROM {self.name}")) - def reset(self): - """Delete the database file.""" - try: - os.remove(self.path) - except FileNotFoundError: - pass - class Downloads(DatabaseBase): """A table that stores the downloaded IDs.""" @@ -180,8 +148,8 @@ class Failed(DatabaseBase): @dataclass(slots=True) class Database: - downloads: DatabaseInterface - failed: DatabaseInterface + downloads: DatabaseBase | Dummy + failed: DatabaseBase | Dummy def downloaded(self, item_id: str) -> bool: return self.downloads.contains(id=item_id) @@ -189,8 +157,5 @@ def downloaded(self, item_id: str) -> bool: def set_downloaded(self, item_id: str): self.downloads.add((item_id,)) - def get_failed_downloads(self) -> list[tuple[str, str, str]]: - return self.failed.all() - def set_failed(self, source: str, media_type: str, id: str): self.failed.add((source, media_type, id)) diff --git a/streamrip/exceptions.py b/streamrip/exceptions.py index 687b1a14..7b742399 100644 --- a/streamrip/exceptions.py +++ b/streamrip/exceptions.py @@ -1,70 +1,87 @@ -"""Streamrip specific exceptions.""" - -from click import echo, style - - -class AuthenticationError(Exception): - """AuthenticationError.""" - - -class MissingCredentialsError(Exception): - """MissingCredentials.""" - - -class IneligibleError(Exception): - """IneligibleError. - - Raised when the account is not eligible to stream a track. - """ - - -class InvalidAppIdError(Exception): - """InvalidAppIdError.""" - - -class InvalidAppSecretError(Exception): - """InvalidAppSecretError.""" - - -class NonStreamableError(Exception): - """Item is not streamable. - - A versatile error that can have many causes. - """ - - def __init__(self, message=None): - """Create a NonStreamable exception. - - :param message: - """ - self.message = message - super().__init__(self.message) - - def print(self, item): - """Print a readable version of the exception. - - :param item: - """ - echo(self.print_msg(item)) - - def print_msg(self, item) -> str: - """Return a generic readable message. - - :param item: - :type item: Media - :rtype: str - """ - base_msg = [style(f"Unable to stream {item!s}.", fg="yellow")] - if self.message: - base_msg.extend( - ( - style("Message:", fg="yellow"), - style(self.message, fg="red"), - ), - ) - - return " ".join(base_msg) - - -class ConversionError(Exception): - """ConversionError.""" +"""Streamrip specific exceptions.""" + +from click import echo, style + + +class AuthenticationError(Exception): + """AuthenticationError.""" + + +class MissingCredentialsError(Exception): + """MissingCredentials.""" + + +class InvalidAppIdError(Exception): + """InvalidAppIdError.""" + + +class InvalidAppSecretError(Exception): + """InvalidAppSecretError.""" + + +class NonStreamableError(Exception): + """Item is not streamable. + + A versatile error that can have many causes. + """ + + def __init__(self, message=None): + """Create a NonStreamable exception. + + :param message: + """ + self.message = message + super().__init__(self.message) + + def print(self, item): + """Print a readable version of the exception. + + :param item: + """ + echo(self.print_msg(item)) + + def print_msg(self, item) -> str: + """Return a generic readable message. + + :param item: + :type item: Media + :rtype: str + """ + base_msg = [style(f"Unable to stream {item!s}.", fg="yellow")] + if self.message: + base_msg.extend( + ( + style("Message:", fg="yellow"), + style(self.message, fg="red"), + ), + ) + + return " ".join(base_msg) + + +class ItemNotFoundError(NonStreamableError): + """The API returned 404 for an item. + + A subclass of NonStreamableError so existing handlers are unaffected, but + distinguishable for callers fetching something optional -- "this does not + exist" and "this failed to download" deserve different log levels. + """ + + +class ConversionError(Exception): + """ConversionError.""" + + +class APIError(Exception): + """A streaming service answered a request with an error. + + The message carries the service's own explanation where it gave one. + """ + + +class TrackDownloadFailedError(Exception): + """Raised when a track fails to download after retrying. + + Signals to Media.rip() that postprocess (tagging, marking downloaded) + must not run for this track. + """ diff --git a/streamrip/filepath_utils.py b/streamrip/filepath_utils.py index 27aef99e..418591b8 100644 --- a/streamrip/filepath_utils.py +++ b/streamrip/filepath_utils.py @@ -1,15 +1,20 @@ +import re from string import printable from pathvalidate import sanitize_filename, sanitize_filepath # type: ignore ALLOWED_CHARS = set(printable) +# Most filesystems (ext4, APFS, NTFS) cap a single file or folder name at 255 +# bytes. Stay a little below it so suffixes streamrip adds while working +# (".dash.mp4", a converted extension) still fit. +MAX_COMPONENT_BYTES = 240 -# TODO: remove this when new pathvalidate release arrives with https://github.com/thombashi/pathvalidate/pull/48 -def truncate_str(text: str) -> str: - str_bytes = text.encode() - str_bytes = str_bytes[:255] - return str_bytes.decode(errors="ignore") + +def truncate_str(text: str, max_bytes: int = MAX_COMPONENT_BYTES) -> str: + """Truncate to at most ``max_bytes`` of UTF-8 without splitting a character.""" + str_bytes = text.encode()[:max_bytes] + return str_bytes.decode(errors="ignore").rstrip() def clean_filename(fn: str, restrict: bool = False) -> str: @@ -25,4 +30,13 @@ def clean_filepath(fn: str, restrict: bool = False) -> str: if restrict: path = "".join(c for c in path if c in ALLOWED_CHARS) - return path + # A formatted folder name can exceed the per-name limit even when every + # field in it was truncated ("File name too long", upstream #856, #859). + parts = re.split(r"([\\/])", path) + return "".join(p if p in ("/", "\\") else truncate_str(p) for p in parts) + + +def fit_filename(stem: str, extension: str) -> str: + """Build ``stem.extension`` shortened so the whole name fits one component.""" + budget = MAX_COMPONENT_BYTES - len(extension.encode()) - 1 + return f"{truncate_str(stem, budget)}.{extension}" diff --git a/streamrip/media/__init__.py b/streamrip/media/__init__.py index 4c478f7f..d587c2ee 100644 --- a/streamrip/media/__init__.py +++ b/streamrip/media/__init__.py @@ -12,20 +12,20 @@ from .track import PendingSingle, PendingTrack, Track __all__ = [ + "Album", + "Artist", + "Label", "Media", "Pending", - "Album", "PendingAlbum", - "Artist", "PendingArtist", - "Label", "PendingLabel", - "Playlist", - "PendingPlaylist", "PendingLastfmPlaylist", - "Track", - "PendingTrack", + "PendingPlaylist", "PendingPlaylistTrack", "PendingSingle", + "PendingTrack", + "Playlist", + "Track", "remove_artwork_tempdirs", ] diff --git a/streamrip/media/album.py b/streamrip/media/album.py index 3ca79a29..265019b6 100644 --- a/streamrip/media/album.py +++ b/streamrip/media/album.py @@ -1,22 +1,47 @@ -import asyncio import logging import os from dataclasses import dataclass from .. import progress -from ..client import Client +from ..client import BasicDownloadable, Client from ..config import Config +from ..console import console from ..db import Database from ..exceptions import NonStreamableError -from ..filepath_utils import clean_filepath +from ..filepath_utils import clean_filename from ..metadata import AlbumMetadata -from ..metadata.util import get_album_track_ids +from ..metadata.util import format_quality, get_album_track_ids from .artwork import download_artwork -from .media import Media, Pending -from .track import PendingTrack +from .media import Media, Pending, rip_tracks +from .track import PendingTrack, album_folder logger = logging.getLogger("streamrip") +# Tracks of an album resolved at once; more only delays the first download, +# and also means more requests landing on the rate limit in the same instant. +RESOLVE_CONCURRENCY = 3 + + +async def download_booklets(session, booklets: list[dict], folder: str): + """Save an album's PDF booklets (Qobuz "goodies") next to its tracks.""" + pdfs = [ + (url, clean_filename(b.get("description") or b.get("name") or "Booklet")) + for b in booklets + if (url := b.get("url") or b.get("original_url") or "").lower().endswith(".pdf") + ] + names = [name for _, name in pdfs] + for n, (url, name) in enumerate(pdfs, 1): + if names.count(name) > 1: + name = f"{name} {n}" + path = os.path.join(folder, f"{name}.pdf") + if os.path.isfile(path): + continue + try: + await BasicDownloadable(session, url, "pdf").download(path, lambda _: None) + except Exception as e: + # A missing booklet is never worth the album. + logger.warning(f"Could not download booklet {url}: {type(e).__name__}: {e}") + @dataclass(slots=True) class Album(Media): @@ -26,30 +51,47 @@ class Album(Media): # folder where the tracks will be downloaded folder: str db: Database + client: Client | None = None - async def preprocess(self): - progress.add_title(self.meta.album) + def _title(self) -> str: + quality = format_quality( + self.meta.info.container, + self.meta.info.bit_depth, + self.meta.info.sampling_rate, + ) + return f"{self.meta.album} {quality}" - async def download(self): - async def _resolve_and_download(pending: Pending): - try: - track = await pending.resolve() - if track is None: - return - await track.rip() - except Exception as e: - logger.error(f"Error downloading track: {e}") - - results = await asyncio.gather( - *[_resolve_and_download(p) for p in self.tracks], return_exceptions=True + async def preprocess(self): + progress.add_title( + id(self), self._title(), self.config.session.cli.progress_bars ) + # Here, not when the album is resolved: artists and labels resolve every + # album before their filters drop some, and those get no booklets. Only + # Qobuz albums have any; a finished album has no folder to put them in. + if ( + self.tracks + and self.client is not None + and self.meta.info.booklets + and self.config.session.qobuz.download_booklets + ): + await download_booklets( + self.client.session, self.meta.info.booklets, self.folder + ) - for result in results: - if isinstance(result, Exception): - logger.error(f"Album track processing error: {result}") + async def download(self): + big = len(self.tracks) > RESOLVE_CONCURRENCY + if big: + console.log(f"Resolving {len(self.tracks)} tracks: {self.meta.album}") + enabled = big and self.config.session.cli.progress_bars + with progress.get_resolve_callback(enabled, f"Obtaining album info: {self.meta.album}"): + await rip_tracks( + self.tracks, + RESOLVE_CONCURRENCY, + self.config.session.metadata.prefer_explicit, + ) async def postprocess(self): - progress.remove_title(self.meta.album) + progress.remove_title(id(self), self.config.session.cli.progress_bars) @dataclass(slots=True) @@ -71,7 +113,7 @@ async def resolve(self) -> Album | None: try: meta = AlbumMetadata.from_album_resp(resp, self.client.source) except Exception as e: - logger.error(f"Error building album metadata for {id=}: {e}") + logger.error(f"Error building album metadata for {self.id}: {e}") return None if meta is None: @@ -81,38 +123,40 @@ async def resolve(self) -> Album | None: return None tracklist = get_album_track_ids(self.client.source, resp) - folder = self.config.session.downloads.folder - album_folder = self._album_folder(folder, meta) - os.makedirs(album_folder, exist_ok=True) + # Tracks already in the database would each be skipped, and logged, one + # by one. Say so once for the album instead, and don't fetch a cover or + # make a folder for an album with nothing left to download. + todo = [track_id for track_id in tracklist if not self.db.downloaded(track_id)] + done = len(tracklist) - len(todo) + if tracklist and not todo: + logger.info(f"{meta.album}: all {done} tracks already downloaded, skipping") + elif done: + logger.info( + f"{meta.album}: skipping {done} of {len(tracklist)} tracks " + "already downloaded" + ) + folder = album_folder(self.config, self.client.source, meta) + if tracklist and not todo: + return Album(meta, [], self.config, folder, self.db) + os.makedirs(folder, exist_ok=True) embed_cover, _ = await download_artwork( self.client.session, - album_folder, + folder, meta.covers, self.config.session.artwork, for_playlist=False, ) pending_tracks = [ PendingTrack( - id, + track_id, album=meta, client=self.client, config=self.config, - folder=album_folder, + folder=folder, db=self.db, cover_path=embed_cover, ) - for id in tracklist + for track_id in todo ] logger.debug("Pending tracks: %s", pending_tracks) - return Album(meta, pending_tracks, self.config, album_folder, self.db) - - def _album_folder(self, parent: str, meta: AlbumMetadata) -> str: - config = self.config.session - if config.downloads.source_subdirectories: - parent = os.path.join(parent, self.client.source.capitalize()) - formatter = config.filepaths.folder_format - folder = clean_filepath( - meta.format_folder_path(formatter), config.filepaths.restrict_characters - ) - - return os.path.join(parent, folder) + return Album(meta, pending_tracks, self.config, folder, self.db, self.client) diff --git a/streamrip/media/artist.py b/streamrip/media/artist.py index 854f6ea0..93bc633b 100644 --- a/streamrip/media/artist.py +++ b/streamrip/media/artist.py @@ -1,10 +1,11 @@ import asyncio import logging import re +from collections.abc import Callable from dataclasses import dataclass from ..client import Client -from ..config import Config, QobuzDiscographyFilterConfig +from ..config import ArtistFilterConfig, Config from ..console import console from ..db import Database from ..exceptions import NonStreamableError @@ -14,15 +15,34 @@ logger = logging.getLogger("streamrip") -# Resolve only N albums at a time to avoid -# initial latency of resolving ALL albums and tracks -# before any downloads -RESOLVE_CHUNK_SIZE = 10 +# One album at a time: downloading several concurrently interleaves their +# tracks in the progress display with no way to tell which track is from +# which album, and it invites the streaming service's rate limit besides. +RESOLVE_CHUNK_SIZE = 1 + + +async def rip_albums( + albums: list[PendingAlbum], wanted: Callable[[Album], bool] = lambda _: True +): + """Resolve and download albums a few at a time; a failure costs one album.""" + # Sliding window, not batches: the next album starts as soon as one finishes. + window = asyncio.Semaphore(RESOLVE_CHUNK_SIZE) + + async def _rip(item: PendingAlbum): + async with window: + try: + album = await item.resolve() + if album is not None and wanted(album): + await album.rip() + except Exception as e: + logger.error(f"Error downloading album: {type(e).__name__}: {e}") + + await asyncio.gather(*map(_rip, albums)) @dataclass(slots=True) class Artist(Media): - """Represents a list of albums. Used by Artist and Label classes.""" + """An artist's discography: a list of albums, optionally filtered.""" name: str albums: list[PendingAlbum] @@ -33,7 +53,15 @@ async def preprocess(self): pass async def download(self): - filter_conf = self.config.session.qobuz_filters + # Fetching each album's tracklist happens RESOLVE_CHUNK_SIZE at a + # time before the first progress bar appears, which for an artist + # with a large discography can take a while with nothing on screen + # to show for it -- so say up front what's queued. + console.print( + f"[bold]{self.name}[/bold]: found {len(self.albums)} release(s), " + "resolving and downloading..." + ) + filter_conf = self.config.session.artist_filters if filter_conf.repeats: console.log( "Resolving [purple]ALL[/purple] artist albums to detect repeats. This may take a while." @@ -45,120 +73,93 @@ async def download(self): async def postprocess(self): pass - async def _resolve_then_download(self, filters: QobuzDiscographyFilterConfig): + async def _resolve_then_download(self, filters: ArtistFilterConfig): """Resolve all artist albums, then download. - This is used if the repeat filter is turned on, since we need the titles - of all albums to remove repeated items. + Used when the repeats filter is on, which needs every album's title + before it can pick one per group. Resolves still go through the same + window as downloads, so this doesn't burst past the rate limit either. """ - resolved_or_none: list[Album | None] = await asyncio.gather( - *[album.resolve() for album in self.albums] + window = asyncio.Semaphore(RESOLVE_CHUNK_SIZE) + + async def _resolve(item: PendingAlbum) -> Album | None: + async with window: + try: + return await item.resolve() + except Exception as e: + logger.error(f"Error resolving album: {type(e).__name__}: {e}") + return None + + resolved = await asyncio.gather(*map(_resolve, self.albums)) + albums = [a for a in resolved if a is not None] + if filters.repeats: + albums = self._filter_repeats(albums) + + async def _rip(album: Album): + async with window: + try: + await album.rip() + except Exception as e: + logger.error(f"Error downloading album: {type(e).__name__}: {e}") + + await asyncio.gather(*[_rip(a) for a in albums if self._wanted(a, filters)]) + + async def _download_async(self, filters: ArtistFilterConfig): + await rip_albums(self.albums, lambda a: self._wanted(a, filters)) + + def _wanted(self, a: Album, f: ArtistFilterConfig) -> bool: + """Whether an album passes every enabled filter except repeats.""" + return not ( + (f.extras and not self._extras(a)) + or (f.features and not self._features(a)) + or (f.non_remaster and not self._non_remaster(a)) + or (f.non_albums and not self._non_albums(a)) ) - resolved = [a for a in resolved_or_none if a is not None] - filtered_albums = self._apply_filters(resolved, filters) - batches = self.batch([a.rip() for a in filtered_albums], RESOLVE_CHUNK_SIZE) - for batch in batches: - await asyncio.gather(*batch) - - async def _download_async(self, filters: QobuzDiscographyFilterConfig): - async def _rip(item: PendingAlbum): - album = await item.resolve() - # Skip if album doesn't pass the filter - if ( - album is None - or (filters.extras and not self._extras(album)) - or (filters.features and not self._features(album)) - or (filters.non_studio_albums and not self._non_studio_albums(album)) - or (filters.non_remaster and not self._non_remaster(album)) - ): - return - await album.rip() - - batches = self.batch( - [_rip(album) for album in self.albums], - RESOLVE_CHUNK_SIZE, - ) - for batch in batches: - await asyncio.gather(*batch) - - def _apply_filters( - self, albums: list[Album], filt: QobuzDiscographyFilterConfig - ) -> list[Album]: - _albums = albums - if filt.repeats: - _albums = self._filter_repeats(_albums) - if filt.extras: - _albums = filter(self._extras, _albums) - if filt.features: - _albums = filter(self._features, _albums) - if filt.non_studio_albums: - _albums = filter(self._non_studio_albums, _albums) - if filt.non_remaster: - _albums = filter(self._non_remaster, _albums) - return list(_albums) - - # Will not fail on any nonempty string - _essence_re = re.compile(r"([^\(\[]+)(?:\s*[\(\[][^\)][\)\]])*") - - @classmethod - def _filter_repeats(cls, albums: list[Album]) -> list[Album]: + + @staticmethod + def _filter_repeats(albums: list[Album]) -> list[Album]: """When there are different versions of an album on the artist, choose the one with the best quality. - It determines that two albums are identical if they have the same title - ignoring contents in brackets or parentheses. + Two albums are versions of each other if their titles match up to the + first bracket or parenthesis ("X" and "X (Deluxe)"). """ groups: dict[str, list[Album]] = {} for a in albums: - match = cls._essence_re.match(a.meta.album) - assert match is not None - title = match.group(1).strip().lower() - items = groups.get(title, []) - items.append(a) - groups[title] = items - - unique_albums: list[Album] = [] - for group in groups.values(): - # Move explicit versions to the beginning - group = sorted( - group, - key=lambda album: album.meta.info.explicit, - reverse=True, - ) - group = sorted( - group, - key=lambda album: album.meta.info.sampling_rate or 0, - reverse=True, - ) - group = sorted( + # A title that starts with a bracket keeps it, or every such + # title would land in one group. + title = re.split(r"[(\[]", a.meta.album, maxsplit=1)[0].strip() + groups.setdefault((title or a.meta.album).lower(), []).append(a) + + return [ + max( group, - key=lambda album: album.meta.info.bit_depth or 0, - reverse=True, + key=lambda a: ( + a.meta.info.bit_depth or 0, + a.meta.info.sampling_rate or 0, + a.meta.info.explicit, + ), ) - # group guaranteed to be nonempty - unique_albums.append(group[0]) - - return unique_albums + for group in groups.values() + ] _extra_re = re.compile( r"(?i)(anniversary|deluxe|live|collector|demo|expanded|remix)" ) # ----- Filter predicates ----- - def _non_studio_albums(self, a: Album) -> bool: - """Filter out non studio albums.""" - return a.meta.albumartist != "Various Artists" and self._extras(a) - def _features(self, a: Album) -> bool: """Filter out features.""" return a.meta.albumartist == self.name def _extras(self, a: Album) -> bool: - """Filter out extras. - - See `_extra_re` for criteria. + """Filter out extras: special editions, live albums, remixes and the + like (see `_extra_re`), and various-artists compilations. """ - return self._extra_re.search(a.meta.album) is None + return ( + a.meta.albumartist != "Various Artists" + and self._extra_re.search(a.meta.album) is None + ) _remaster_re = re.compile(r"(?i)(re)?master(ed)?") @@ -168,13 +169,8 @@ def _non_remaster(self, a: Album) -> bool: def _non_albums(self, a: Album) -> bool: """Filter out singles.""" - return len(a.tracks) > 1 - - @staticmethod - def batch(iterable, n=1): - total = len(iterable) - for ndx in range(0, total, n): - yield iterable[ndx : min(ndx + n, total)] + # Not len(a.tracks): tracks already downloaded aren't in that list. + return a.meta.tracktotal > 1 @dataclass(slots=True) diff --git a/streamrip/media/label.py b/streamrip/media/label.py index 5aa0e807..08b6e3d6 100644 --- a/streamrip/media/label.py +++ b/streamrip/media/label.py @@ -1,14 +1,14 @@ -import asyncio import logging from dataclasses import dataclass -from streamrip.exceptions import NonStreamableError - from ..client import Client from ..config import Config +from ..console import console from ..db import Database +from ..exceptions import NonStreamableError from ..metadata import LabelMetadata from .album import PendingAlbum +from .artist import rip_albums from .media import Media, Pending logger = logging.getLogger("streamrip") @@ -16,7 +16,7 @@ @dataclass(slots=True) class Label(Media): - """Represents a list of albums. Used by Artist and Label classes.""" + """A record label's catalog: a list of albums.""" name: str albums: list[PendingAlbum] @@ -27,33 +27,18 @@ async def preprocess(self): pass async def download(self): - # Resolve only 3 albums at a time to avoid - # initial latency of resolving ALL albums and tracks - # before any downloads - album_resolve_chunk_size = 10 - - async def _resolve_download(item: PendingAlbum): - album = await item.resolve() - if album is None: - return - await album.rip() - - batches = self.batch( - [_resolve_download(album) for album in self.albums], - album_resolve_chunk_size, + # Fetching each album's tracklist happens a few at a time before the + # first progress bar appears, which for a label with a large + # catalog can take a while with nothing on screen to show for it. + console.print( + f"[bold]{self.name}[/bold]: found {len(self.albums)} release(s), " + "resolving and downloading..." ) - for batch in batches: - await asyncio.gather(*batch) + await rip_albums(self.albums) async def postprocess(self): pass - @staticmethod - def batch(iterable, n=1): - total = len(iterable) - for ndx in range(0, total, n): - yield iterable[ndx : min(ndx + n, total)] - @dataclass(slots=True) class PendingLabel(Pending): diff --git a/streamrip/media/media.py b/streamrip/media/media.py index 78ad3945..eeeeb05a 100644 --- a/streamrip/media/media.py +++ b/streamrip/media/media.py @@ -1,5 +1,11 @@ +import asyncio +import logging from abc import ABC, abstractmethod +from ..exceptions import TrackDownloadFailedError + +logger = logging.getLogger("streamrip") + class Media(ABC): async def rip(self): @@ -30,3 +36,71 @@ class Pending(ABC): async def resolve(self) -> Media | None: """Fetch metadata and resolve into a downloadable `Media` object.""" raise NotImplementedError + + +async def resolve_or_none(pending: Pending) -> Media | None: + """Resolve one item; a failure is logged and costs only that item.""" + try: + return await pending.resolve() + except Exception as e: + item = getattr(pending, "id", "") + logger.error(f"Error resolving {item}: {type(e).__name__}: {e}") + return None + + +def filter_prefer_explicit(tracks: list) -> list: + """Drop the clean copy of any track that also has an explicit copy. + + Tracks are grouped by (title, artist); within a group that has both an + explicit and a non-explicit entry, only the explicit one(s) are kept. A + group that's all-explicit or all-clean is left untouched -- there's + nothing to prefer against. Used by Album/Playlist when [metadata] + prefer_explicit is set, so a catalog that lists both editions of the + same song doesn't turn into two downloads of it. + """ + groups: dict[tuple[str, str], list] = {} + for track in tracks: + key = (track.meta.title.strip().lower(), track.meta.artist.strip().lower()) + groups.setdefault(key, []).append(track) + + kept = [] + for group in groups.values(): + explicit = [t for t in group if t.meta.info.explicit] + kept.extend(explicit if 0 < len(explicit) < len(group) else group) + return kept + + +async def rip_tracks(pending: list, resolve_concurrency: int, prefer_explicit: bool): + """Resolve and download the tracks of an album or playlist. + + At most `resolve_concurrency` tracks resolve at once, and each downloads + as soon as it's resolved, outside that limit (downloads have their own). + A failure costs only that track. With prefer_explicit, every track is + resolved first, so a clean copy can be dropped for an explicit one. + """ + slots = asyncio.Semaphore(resolve_concurrency) + + async def resolve(item): + async with slots: + return await resolve_or_none(item) + + async def rip(track): + try: + await track.rip() + except TrackDownloadFailedError: + pass # already logged and recorded by Track.download() + except Exception as e: + # Include the type: some exceptions have an empty message (#938). + logger.error(f"Error downloading track: {type(e).__name__}: {e}") + + if prefer_explicit: + resolved = await asyncio.gather(*map(resolve, pending)) + tracks = [t for t in resolved if t is not None] + await asyncio.gather(*map(rip, filter_prefer_explicit(tracks))) + return + + async def resolve_and_rip(item): + if (track := await resolve(item)) is not None: + await rip(track) + + await asyncio.gather(*map(resolve_and_rip, pending)) diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 383f2465..30f7f796 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -2,35 +2,38 @@ import html import logging import os -import random import re -from contextlib import ExitStack +from contextlib import nullcontext from dataclasses import dataclass import aiohttp from rich.text import Text from .. import progress -from ..client import Client +from ..client import Client, new_session from ..config import Config from ..console import console from ..db import Database from ..exceptions import NonStreamableError -from ..filepath_utils import clean_filepath -from ..metadata import ( - AlbumMetadata, - Covers, - PlaylistMetadata, - SearchResults, - TrackMetadata, -) -from ..utils.ssl_utils import get_aiohttp_connector_kwargs +from ..filepath_utils import clean_filename +from ..metadata import PlaylistMetadata, SearchResults from .artwork import download_artwork -from .media import Media, Pending -from .track import Track +from .media import Media, Pending, rip_tracks +from .track import Track, fetch_downloadable, fetch_track_meta logger = logging.getLogger("streamrip") +# Tracks of a playlist resolved at once. Playlist tracks come from different +# albums, so each resolve also fetches a cover; more than this only delays +# the first download. +RESOLVE_CONCURRENCY = 20 + + +def _playlist_folder(config: Config, name: str) -> str: + c = config.session + folder = clean_filename(name, c.filepaths.restrict_characters) + return os.path.join(c.downloads.folder, folder) + @dataclass(slots=True) class PendingPlaylistTrack(Pending): @@ -41,67 +44,40 @@ class PendingPlaylistTrack(Pending): playlist_name: str position: int db: Database + # Number of tracks in the playlist, for the track total when renumbering. + total: int = 0 async def resolve(self) -> Track | None: - if self.db.downloaded(self.id): - logger.info(f"Track ({self.id}) already logged in database. Skipping.") - return None - try: - resp = await self.client.get_metadata(self.id, "track") - except NonStreamableError as e: - logger.error(f"Could not stream track {self.id}: {e}") - return None - - album = AlbumMetadata.from_track_resp(resp, self.client.source) - if album is None: - logger.error( - f"Track ({self.id}) not available for stream on {self.client.source}", - ) - self.db.set_failed(self.client.source, "track", self.id) - return None - meta = TrackMetadata.from_resp(album, self.client.source, resp) + meta = await fetch_track_meta(self.client, self.db, self.id) if meta is None: - logger.error( - f"Track ({self.id}) not available for stream on {self.client.source}", - ) - self.db.set_failed(self.client.source, "track", self.id) return None - - c = self.config.session.metadata - if c.renumber_playlist_tracks: - meta.tracknumber = self.position - if c.set_playlist_to_album: + album, c = meta.album, self.config.session + if c.metadata.renumber_playlist_tracks: + # Disc and total come from the track's own album; left alone, a + # disc-2 track sorts after the rest and "5/12" in a 50-track list. + meta.tracknumber, meta.discnumber = self.position, 1 + album.tracktotal, album.disctotal = self.total or album.tracktotal, 1 + if c.metadata.set_playlist_to_album: + # Music servers group albums by album artist as well, so each + # track's own would split the playlist into one album per artist + # (upstream PR #738). album.album = self.playlist_name - - quality = self.config.session.get_source(self.client.source).quality - try: - embedded_cover_path, downloadable = await asyncio.gather( - self._download_cover(album.covers, self.folder), - self.client.get_downloadable(self.id, quality), - ) - except NonStreamableError as e: - logger.error(f"Error fetching download info for track {self.id}: {e}") - self.db.set_failed(self.client.source, "track", self.id) - return None - - return Track( - meta, - downloadable, - self.config, - self.folder, - embedded_cover_path, - self.db, + album.albumartist = "Various Artists" + album.compilation = "1" + + (cover_path, _), downloadable = await asyncio.gather( + download_artwork( + self.client.session, + self.folder, + album.covers, + c.artwork, + for_playlist=True, + ), + fetch_downloadable(self.client, self.config, self.db, self.id), ) - - async def _download_cover(self, covers: Covers, folder: str) -> str | None: - embed_path, _ = await download_artwork( - self.client.session, - folder, - covers, - self.config.session.artwork, - for_playlist=True, - ) - return embed_path + if downloadable is None: + return None + return Track(meta, downloadable, self.config, self.folder, cover_path, self.db) @dataclass(slots=True) @@ -112,40 +88,22 @@ class Playlist(Media): tracks: list[PendingPlaylistTrack] async def preprocess(self): - progress.add_title(self.name) + progress.add_title(id(self), self.name, self.config.session.cli.progress_bars) async def postprocess(self): - progress.remove_title(self.name) + progress.remove_title(id(self), self.config.session.cli.progress_bars) async def download(self): - track_resolve_chunk_size = 20 - - async def _resolve_download(item: PendingPlaylistTrack): - try: - track = await item.resolve() - if track is None: - return - await track.rip() - except Exception as e: - logger.error(f"Error downloading track: {e}") - - batches = self.batch( - [_resolve_download(track) for track in self.tracks], - track_resolve_chunk_size, - ) - - for batch in batches: - results = await asyncio.gather(*batch, return_exceptions=True) - - for result in results: - if isinstance(result, Exception): - logger.error(f"Batch processing error: {result}") - - @staticmethod - def batch(iterable, n=1): - total = len(iterable) - for ndx in range(0, total, n): - yield iterable[ndx : min(ndx + n, total)] + big = len(self.tracks) > RESOLVE_CONCURRENCY + if big: + console.log(f"Resolving {len(self.tracks)} tracks: {self.name}") + enabled = big and self.config.session.cli.progress_bars + with progress.get_resolve_callback(enabled, f"Obtaining playlist info: {self.name}"): + await rip_tracks( + self.tracks, + RESOLVE_CONCURRENCY, + self.config.session.metadata.prefer_explicit, + ) @dataclass(slots=True) @@ -170,8 +128,8 @@ async def resolve(self) -> Playlist | None: logger.error(f"Error creating playlist: {e}") return None name = meta.name - parent = self.config.session.downloads.folder - folder = os.path.join(parent, clean_filepath(name)) + folder = _playlist_folder(self.config, name) + ids = meta.ids() tracks = [ PendingPlaylistTrack( id, @@ -179,10 +137,11 @@ async def resolve(self) -> Playlist | None: self.config, folder, name, - position + 1, + position, self.db, + total=len(ids), ) - for position, id in enumerate(meta.ids()) + for position, id in enumerate(ids, start=1) ] return Playlist(name, self.config, self.client, tracks) @@ -218,37 +177,29 @@ async def resolve(self) -> Playlist | None: self.lastfm_url, ) except Exception as e: - logger.error("Error occured while parsing last.fm page: %s", e) + logger.error("Error occurred while parsing last.fm page: %s", e) return None - requests = [] - s = self.Status(0, 0, len(titles_artists)) - if self.config.session.cli.progress_bars: - with console.status(s.text(), spinner="moon") as status: - - def callback(): - status.update(s.text()) - - for title, artist in titles_artists: - requests.append(self._make_query(f"{title} {artist}", s, callback)) - results: list[tuple[str | None, bool]] = await asyncio.gather(*requests) - else: + show = self.config.session.cli.progress_bars + with ( + console.status(s.text(), spinner="moon") if show else nullcontext() as spin + ): def callback(): - pass + if spin is not None: + spin.update(s.text()) - for title, artist in titles_artists: - requests.append(self._make_query(f"{title} {artist}", s, callback)) - results: list[tuple[str | None, bool]] = await asyncio.gather(*requests) + results = await asyncio.gather( + *(self._make_query(f"{t} {a}", s, callback) for t, a in titles_artists) + ) - parent = self.config.session.downloads.folder - folder = os.path.join(parent, clean_filepath(playlist_title)) + folder = _playlist_folder(self.config, playlist_title) pending_tracks = [] for pos, (id, from_fallback) in enumerate(results, start=1): if id is None: - logger.warning(f"No results found for {titles_artists[pos-1]}") + logger.warning(f"No results found for {titles_artists[pos - 1]}") continue if from_fallback: @@ -266,64 +217,43 @@ def callback(): playlist_title, pos, self.db, + total=len(results), ), ) return Playlist(playlist_title, self.config, self.client, pending_tracks) async def _make_query( - self, - query: str, - search_status: Status, - callback, + self, query: str, status: Status, callback ) -> tuple[str | None, bool]: - """Search for a track with the main source, and use fallback source - if that fails. - - Args: - ---- - query (str): Query to search - s (Status): - callback: function to call after each query completes + """Search the main source, then the fallback, for one track. - Returns: A 2-tuple, where the first element contains the ID if it was found, - and the second element is True if the fallback source was used. + Returns the first hit's ID (None if nothing matched) and whether it + came from the fallback source. A search that errors counts as no hit, + so one bad query doesn't sink the whole playlist. """ - with ExitStack() as stack: - # ensure `callback` is always called - stack.callback(callback) - pages = await self.client.search("track", query, limit=1) - if len(pages) > 0: - logger.debug(f"Found result for {query} on {self.client.source}") - search_status.found += 1 - return ( - SearchResults.from_pages(self.client.source, "track", pages) - .results[0] - .id - ), False - - if self.fallback_client is None: - logger.debug(f"No result found for {query} on {self.client.source}") - search_status.failed += 1 - return None, False - - pages = await self.fallback_client.search("track", query, limit=1) - if len(pages) > 0: - logger.debug(f"Found result for {query} on {self.client.source}") - search_status.found += 1 - return ( - SearchResults.from_pages( - self.fallback_client.source, - "track", - pages, - ) - .results[0] - .id - ), True - - logger.debug(f"No result found for {query} on {self.client.source}") - search_status.failed += 1 - return None, True + hit: tuple[str | None, bool] = (None, False) + for client, is_fallback in ((self.client, False), (self.fallback_client, True)): + if client is None: + continue + try: + pages = await client.search("track", query, limit=1) + found = SearchResults.from_pages(client.source, "track", pages) + except Exception as e: + logger.warning(f"Searching {client.source} for {query!r} failed: {e}") + continue + if found.results: + logger.debug(f"Found result for {query} on {client.source}") + hit = (found.results[0].id, is_fallback) + break + + if hit[0] is None: + logger.debug(f"No result found for {query}") + status.failed += 1 + else: + status.found += 1 + callback() + return hit async def _parse_lastfm_playlist( self, @@ -358,12 +288,10 @@ async def fetch(session: aiohttp.ClientSession, url, **kwargs): async with session.get(url, **kwargs) as resp: return await resp.text("utf-8") - # Create new session so we're not bound by rate limit - verify_ssl = getattr(self.config.session.downloads, "verify_ssl", True) - connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=verify_ssl) - connector = aiohttp.TCPConnector(**connector_kwargs) - - async with aiohttp.ClientSession(connector=connector) as session: + # A session of its own, so these requests don't count against the + # client's rate limit. + verify_ssl = self.config.session.downloads.verify_ssl + async with new_session(verify_ssl=verify_ssl) as session: page = await fetch(session, playlist_url) playlist_title_match = re_playlist_title_match.search(page) if playlist_title_match is None: @@ -394,17 +322,3 @@ async def fetch(session: aiohttp.ClientSession, url, **kwargs): title_artist_pairs.extend(find_title_artist_pairs(page)) return playlist_title, title_artist_pairs - - async def _make_query_mock( - self, - _: str, - s: Status, - callback, - ) -> tuple[str | None, bool]: - await asyncio.sleep(random.uniform(1, 20)) - if random.randint(0, 4) >= 1: - s.found += 1 - else: - s.failed += 1 - callback() - return None, False diff --git a/streamrip/media/semaphore.py b/streamrip/media/semaphore.py index c71ea89c..0faa10a3 100644 --- a/streamrip/media/semaphore.py +++ b/streamrip/media/semaphore.py @@ -4,37 +4,26 @@ from ..config import DownloadsConfig _unlimited = nullcontext() -_global_semaphore: None | tuple[int, asyncio.Semaphore] = None +_global_semaphore: tuple[int, asyncio.Semaphore] | None = None def global_download_semaphore(c: DownloadsConfig) -> asyncio.Semaphore | nullcontext: - """A global semaphore that limit the number of total tracks being downloaded - at once. + """A global semaphore that limits how many tracks download at once. - If concurrency is disabled in the config, the semaphore is set to 1. - Otherwise it's set to `max_connections`. - A negative `max_connections` value means there is no maximum and no semaphore is used. - - Since it is global, only one value of `max_connections` is allowed per session. + That's `max_connections` (1: one after the other); 0 or less means no + limit. Since it is global, only one value is allowed per session. """ - global _unlimited, _global_semaphore - - if c.concurrency: - max_connections = c.max_connections if c.max_connections > 0 else None - else: - max_connections = 1 - - if max_connections is None: - return _unlimited + global _global_semaphore + max_connections = c.max_connections if max_connections <= 0: - raise Exception(f"{max_connections = } too small") + return _unlimited if _global_semaphore is None: _global_semaphore = (max_connections, asyncio.Semaphore(max_connections)) - assert ( - max_connections == _global_semaphore[0] - ), f"Already have other global semaphore {_global_semaphore}" + assert max_connections == _global_semaphore[0], ( + f"Already have other global semaphore {_global_semaphore}" + ) return _global_semaphore[1] diff --git a/streamrip/media/track.py b/streamrip/media/track.py index bc3207d5..fe1a07a7 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -3,13 +3,19 @@ import os from dataclasses import dataclass +import mutagen +from mutagen.aiff import AIFF +from mutagen.flac import FLAC + from .. import converter from ..client import Client, Downloadable from ..config import Config from ..db import Database -from ..exceptions import NonStreamableError -from ..filepath_utils import clean_filename -from ..metadata import AlbumMetadata, Covers, TrackMetadata, tag_file +from ..exceptions import TrackDownloadFailedError +from ..filepath_utils import clean_filename, clean_filepath, fit_filename +from ..metadata import AlbumMetadata, TrackMetadata, tag_file +from ..metadata.tagger import TAGGABLE_EXTENSIONS +from ..metadata.util import format_quality from ..progress import add_title, get_progress_callback, remove_title from .artwork import download_artwork from .media import Media, Pending @@ -17,6 +23,30 @@ logger = logging.getLogger("streamrip") +# One try plus three retries with exponential backoff. +MAX_DOWNLOAD_ATTEMPTS = 4 + +# Formats a track is downloaded in. A lossless copy of a track makes a lossy +# copy of the same track redundant, so only one of them is kept. +DEDUP_EXTENSIONS = ("flac", "m4a", "mp3") + + +def _open_audio(path: str): + """`path` as a mutagen file with format-independent tag names, or None.""" + try: + return mutagen.File(path, easy=True) + except Exception: + return None + + +def _is_lossless(audio) -> bool: + # ALAC and AAC share the .m4a extension, so this has to look at the codec. + return isinstance(audio, FLAC | AIFF) or getattr(audio.info, "codec", "") == "alac" + + +def _first_tag(audio, key: str) -> str: + return str((audio.tags.get(key) or [""])[0]).strip().casefold() + @dataclass(slots=True) class Track(Media): @@ -27,72 +57,137 @@ class Track(Media): # Is None if a cover doesn't exist for the track cover_path: str | None db: Database - # change? download_path: str = "" is_single: bool = False + # Base name (no extension) `download_path` is built from; kept around to + # find a sibling file that only differs by extension. + _path_stem: str = "" + # Set in preprocess() when a lossless copy of this track already exists, + # so download()/postprocess() skip it instead of fetching a redundant + # lossy copy. + _skip_lossy_duplicate: bool = False async def preprocess(self): self._set_download_path() os.makedirs(self.folder, exist_ok=True) + if self.downloadable.extension != "flac" and self._copies_on_disk( + lossless=True + ): + self._skip_lossy_duplicate = True + logger.info( + f"Skipping '{self.meta.title}': a lossless copy already exists, " + f"not downloading the .{self.downloadable.extension} version." + ) + return if self.is_single: - add_title(self.meta.title) + add_title(id(self), self.meta.title, self.config.session.cli.progress_bars) async def download(self): - # TODO: progress bar description + if self._skip_lossy_duplicate: + return + quality = format_quality( + self.meta.album.info.container, + self.meta.album.info.bit_depth, + self.meta.album.info.sampling_rate, + ) async with global_download_semaphore(self.config.session.downloads): - with get_progress_callback( - self.config.session.cli.progress_bars, - await self.downloadable.size(), - f"Track {self.meta.tracknumber}", - ) as callback: + for attempt in range(1, MAX_DOWNLOAD_ATTEMPTS + 1): + # Retries continue the partial file instead of starting over, + # so a connection that keeps dropping near the end of a large + # FLAC still gets there (upstream #951, #1022). + self.downloadable.resume = attempt > 1 + label = f"Track {self.meta.tracknumber} {quality}" + if attempt > 1: + label += f" (retry {attempt - 1})" try: - await self.downloadable.download(self.download_path, callback) - retry = False + with get_progress_callback( + self.config.session.cli.progress_bars, + await self.downloadable.size(), + label, + ) as callback: + await self.downloadable.download(self.download_path, callback) + return + except asyncio.CancelledError: + raise except Exception as e: - logger.error( - f"Error downloading track '{self.meta.title}', retrying: {e}" - ) - retry = True - - if not retry: - return + error = f"{type(e).__name__}: {e}" + if attempt < MAX_DOWNLOAD_ATTEMPTS: + delay = 2**attempt # 2s, 4s, 8s + logger.warning( + f"Error downloading track '{self.meta.title}', " + f"retrying in {delay}s: {error}" + ) + await asyncio.sleep(delay) + continue - with get_progress_callback( - self.config.session.cli.progress_bars, - await self.downloadable.size(), - f"Track {self.meta.tracknumber} (retry)", - ) as callback: - try: - await self.downloadable.download(self.download_path, callback) - except Exception as e: logger.error( - f"Persistent error downloading track '{self.meta.title}', skipping: {e}" + f"Persistent error downloading track '{self.meta.title}', " + f"skipping: {error}" ) self.db.set_failed( self.downloadable.source, "track", self.meta.info.id ) + if os.path.isfile(self.download_path): + os.remove(self.download_path) + # postprocess() normally does this, but raising below + # skips it, which would leave a phantom title in the + # progress display for the rest of the run. + if self.is_single: + remove_title(id(self), self.config.session.cli.progress_bars) + raise TrackDownloadFailedError( + f"{self.meta.title} ({self.meta.info.id})" + ) from e async def postprocess(self): + if self._skip_lossy_duplicate: + self.db.set_downloaded(self.meta.info.id) + return + if self.is_single: - remove_title(self.meta.title) + remove_title(id(self), self.config.session.cli.progress_bars) - await tag_file(self.download_path, self.meta, self.cover_path) + exclude = self.config.session.metadata.exclude + await tag_file(self.download_path, self.meta, self.cover_path, exclude) if self.config.session.conversion.enabled: - await self._convert() - + try: + await self._convert() + except Exception as e: + # The download itself is fine and fully tagged; keep it and + # still record it, instead of re-downloading it on every run + # (upstream #1010, e.g. ffmpeg without libfdk_aac). + logger.error( + f"Could not convert '{self.meta.title}', keeping the " + f"original file: {type(e).__name__}: {e}" + ) + + self._remove_lossy_copies() self.db.set_downloaded(self.meta.info.id) async def _convert(self): c = self.config.session.conversion engine_class = converter.get(c.codec) + # Lossy codecs honour [conversion] lossy_bitrate (upstream #823). + ffmpeg_arg = None + if not engine_class.lossless: + ffmpeg_arg = engine_class.get_quality_arg(c.lossy_bitrate) engine = engine_class( filename=self.download_path, + ffmpeg_arg=ffmpeg_arg, sampling_rate=c.sampling_rate, bit_depth=c.bit_depth, remove_source=True, # always going to delete the old file ) await engine.convert() self.download_path = engine.final_fn # because the extension changed + # ffmpeg does not reliably carry every tag into the new container, so + # re-tag the converted file where we know how to. + if self.download_path.rsplit(".", 1)[-1].lower() in TAGGABLE_EXTENSIONS: + await tag_file( + self.download_path, + self.meta, + self.cover_path, + self.config.session.metadata.exclude, + ) def _set_download_path(self): c = self.config.session.filepaths @@ -104,14 +199,125 @@ def _set_download_path(self): if c.truncate_to > 0 and len(track_path) > c.truncate_to: track_path = track_path[: c.truncate_to] + self._path_stem = track_path self.download_path = os.path.join( self.folder, - f"{track_path}.{self.downloadable.extension}", + fit_filename(track_path, self.downloadable.extension), + ) + + def _sibling_path(self, extension: str) -> str: + """Path this track would have on disk with a different extension.""" + return os.path.join(self.folder, fit_filename(self._path_stem, extension)) + + def _copies_on_disk(self, lossless: bool) -> list[str]: + """Lossless (or lossy) files of this same track under another extension. + + A matching filename stem alone doesn't prove it's the same track: a + single and an album can share one. So a file only counts if its title + and album tags match this track's too. + """ + want = ( + self.meta.title.strip().casefold(), + self.meta.album.album.strip().casefold(), + ) + copies = [] + for ext in DEDUP_EXTENSIONS: + path = self._sibling_path(ext) + if path == self.download_path or not os.path.isfile(path): + continue + audio = _open_audio(path) + if ( + audio is not None + and audio.tags + and _is_lossless(audio) == lossless + and (_first_tag(audio, "title"), _first_tag(audio, "album")) == want + ): + copies.append(path) + return copies + + def _remove_lossy_copies(self): + # A lossy download converted to FLAC is no better than the copy it would + # replace; only a genuinely lossless download supersedes the others. + if self.downloadable.extension != "flac": + return + final = _open_audio(self.download_path) + if final is None or not _is_lossless(final): + return + for path in self._copies_on_disk(lossless=False): + logger.info(f"Removing lower-quality copy: {path}") + os.remove(path) + + +def album_folder(config: Config, source: str, album: AlbumMetadata) -> str: + """The folder an album's tracks go in.""" + c = config.session + parent = c.downloads.folder + if c.downloads.source_subdirectories: + parent = os.path.join(parent, source.capitalize()) + folder = album.format_folder_path(c.filepaths.folder_format) + return os.path.join(parent, clean_filepath(folder, c.filepaths.restrict_characters)) + + +def _record_failure(db: Database, source: str, track_id: str, message: str): + # Every failure has to be recorded, not just logged: otherwise the track + # silently goes missing, with nothing for `streamrip repair` to retry. + logger.error(message) + db.set_failed(source, "track", track_id) + + +async def fetch_track_meta( + client: Client, db: Database, track_id: str, album: AlbumMetadata | None = None +) -> TrackMetadata | None: + """A track's metadata, or None if it's already downloaded or unavailable. + + Without `album`, the album's metadata is read from the track's. + """ + if db.downloaded(track_id): + logger.info(f"Skipping track {track_id}. Marked as downloaded in the database.") + return None + source = client.source + try: + resp = await client.get_metadata(track_id, "track") + album = album or AlbumMetadata.from_track_resp(resp, source) + meta = album and TrackMetadata.from_resp(album, source, resp) + except Exception as e: + _record_failure( + db, + source, + track_id, + f"Error fetching track {track_id}: {type(e).__name__}: {e}", + ) + return None + if meta is None: + _record_failure( + db, + source, + track_id, + f"Track {track_id} not available for stream on {source}", ) + return meta + + +async def fetch_downloadable( + client: Client, config: Config, db: Database, track_id: str +) -> Downloadable | None: + quality = config.session.get_source(client.source).quality + try: + return await client.get_downloadable(track_id, quality) + except Exception as e: + _record_failure( + db, + client.source, + track_id, + f"Error fetching download info for track {track_id}: {type(e).__name__}: {e}", + ) + return None @dataclass(slots=True) class PendingTrack(Pending): + """A track of an album whose metadata and cover are already there.""" + id: str album: AlbumMetadata client: Client @@ -122,61 +328,27 @@ class PendingTrack(Pending): cover_path: str | None async def resolve(self) -> Track | None: - if self.db.downloaded(self.id): - logger.info( - f"Skipping track {self.id}. Marked as downloaded in the database.", - ) - return None - - source = self.client.source - try: - resp = await self.client.get_metadata(self.id, "track") - except NonStreamableError as e: - logger.error(f"Track {self.id} not available for stream on {source}: {e}") - return None - - try: - meta = TrackMetadata.from_resp(self.album, source, resp) - except Exception as e: - logger.error(f"Error building track metadata for {self.id}: {e}") - return None - + meta = await fetch_track_meta(self.client, self.db, self.id, self.album) if meta is None: - logger.error(f"Track {self.id} not available for stream on {source}") - self.db.set_failed(source, "track", self.id) return None - - quality = self.config.session.get_source(source).quality - try: - downloadable = await self.client.get_downloadable(self.id, quality) - except NonStreamableError as e: - logger.error( - f"Error getting downloadable data for track {meta.tracknumber} [{self.id}]: {e}" - ) - return None - - downloads_config = self.config.session.downloads - if downloads_config.disc_subdirectories and self.album.disctotal > 1: - folder = os.path.join(self.folder, f"Disc {meta.discnumber}") - else: - folder = self.folder - - return Track( - meta, - downloadable, - self.config, - folder, - self.cover_path, - self.db, + downloadable = await fetch_downloadable( + self.client, self.config, self.db, self.id ) + if downloadable is None: + return None + folder = self.folder + if ( + self.config.session.downloads.disc_subdirectories + and self.album.disctotal > 1 + ): + folder = os.path.join(folder, f"Disc {meta.discnumber}") + return Track(meta, downloadable, self.config, folder, self.cover_path, self.db) @dataclass(slots=True) class PendingSingle(Pending): - """Whereas PendingTrack is used in the context of an album, where the album metadata - and cover have been resolved, PendingSingle is used when a single track is downloaded. - - This resolves the Album metadata and downloads the cover to pass to the Track class. + """A track downloaded on its own, which brings its album's metadata and + cover with it. """ id: str @@ -185,84 +357,33 @@ class PendingSingle(Pending): db: Database async def resolve(self) -> Track | None: - if self.db.downloaded(self.id): - logger.info( - f"Skipping track {self.id}. Marked as downloaded in the database.", - ) - return None - - try: - resp = await self.client.get_metadata(self.id, "track") - except NonStreamableError as e: - logger.error(f"Error fetching track {self.id}: {e}") - return None - # Patch for soundcloud - try: - album = AlbumMetadata.from_track_resp(resp, self.client.source) - except Exception as e: - logger.error(f"Error building album metadata for track {id=}: {e}") - return None - - if album is None: - self.db.set_failed(self.client.source, "track", self.id) - logger.error( - f"Cannot stream track (am) ({self.id}) on {self.client.source}", - ) - return None - - try: - meta = TrackMetadata.from_resp(album, self.client.source, resp) - except Exception as e: - logger.error(f"Error building track metadata for track {id=}: {e}") - return None - + meta = await fetch_track_meta(self.client, self.db, self.id) if meta is None: - self.db.set_failed(self.client.source, "track", self.id) - logger.error( - f"Cannot stream track (tm) ({self.id}) on {self.client.source}", - ) return None - - config = self.config.session - quality = getattr(config, self.client.source).quality - assert isinstance(quality, int) - parent = config.downloads.folder - if config.filepaths.add_singles_to_folder: - folder = self._format_folder(album) + album, c = meta.album, self.config.session + in_album_folder = c.filepaths.add_singles_to_folder + if in_album_folder: + folder = album_folder(self.config, self.client.source, album) else: - folder = parent - + folder = c.downloads.folder os.makedirs(folder, exist_ok=True) - embedded_cover_path, downloadable = await asyncio.gather( - self._download_cover(album.covers, folder), - self.client.get_downloadable(self.id, quality), - ) - return Track( - meta, - downloadable, - self.config, - folder, - embedded_cover_path, - self.db, - is_single=True, + (cover_path, _), downloadable = await asyncio.gather( + download_artwork( + self.client.session, folder, album.covers, c.artwork, for_playlist=False + ), + fetch_downloadable(self.client, self.config, self.db, self.id), ) + if downloadable is None: + return None - def _format_folder(self, meta: AlbumMetadata) -> str: - c = self.config.session - parent = c.downloads.folder - formatter = c.filepaths.folder_format - if c.downloads.source_subdirectories: - parent = os.path.join(parent, self.client.source.capitalize()) - - return os.path.join(parent, meta.format_folder_path(formatter)) - - async def _download_cover(self, covers: Covers, folder: str) -> str | None: - embed_path, _ = await download_artwork( - self.client.session, - folder, - covers, - self.config.session.artwork, - for_playlist=False, + # As in an album download, a track of a multi-disc album goes in its + # disc's subfolder (`streamrip repair` downloads singles too). Only + # when it's in the album's folder, not as a bare "Disc N" in the root. + if in_album_folder and c.downloads.disc_subdirectories and album.disctotal > 1: + folder = os.path.join(folder, f"Disc {meta.discnumber}") + os.makedirs(folder, exist_ok=True) + + return Track( + meta, downloadable, self.config, folder, cover_path, self.db, is_single=True ) - return embed_path diff --git a/streamrip/metadata/__init__.py b/streamrip/metadata/__init__.py index 88f4d778..7924ddf3 100644 --- a/streamrip/metadata/__init__.py +++ b/streamrip/metadata/__init__.py @@ -19,21 +19,21 @@ from .track import TrackInfo, TrackMetadata __all__ = [ - "AlbumMetadata", - "ArtistMetadata", "AlbumInfo", - "TrackInfo", - "LabelMetadata", - "TrackMetadata", - "PlaylistMetadata", - "Covers", - "tag_file", - "util", + "AlbumMetadata", "AlbumSummary", + "ArtistMetadata", "ArtistSummary", + "Covers", + "LabelMetadata", "LabelSummary", + "PlaylistMetadata", "PlaylistSummary", + "SearchResults", "Summary", + "TrackInfo", + "TrackMetadata", "TrackSummary", - "SearchResults", + "tag_file", + "util", ] diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index 01b48ed2..10a7a31b 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -7,7 +7,7 @@ from ..filepath_utils import clean_filename, clean_filepath from .covers import Covers -from .util import get_quality_id, safe_get, typed +from .util import deezer_artists, get_quality_id, safe_get, tidal_quality_id, typed PHON_COPYRIGHT = "\u2117" COPYRIGHT = "\u00a9" @@ -18,6 +18,10 @@ genre_clean = re.compile(r"([^\u2192\/]+)") +def _year(date: str | None) -> str: + return date[:4] if date else "Unknown" + + @dataclass(slots=True) class AlbumInfo: id: str @@ -41,15 +45,13 @@ class AlbumMetadata: tracktotal: int disctotal: int = 1 albumcomposer: str | None = None - comment: str | None = None + # Set on the tracks of a playlist downloaded as one album. compilation: str | None = None copyright: str | None = None date: str | None = None description: str | None = None - encoder: str | None = None - grouping: str | None = None - lyrics: str | None = None - purchase_date: str | None = None + # Edition name, e.g. "Deluxe Edition". Only some sources provide one. + version: str | None = None def get_genres(self) -> str: return ", ".join(self.genre) @@ -64,7 +66,11 @@ def get_copyright(self) -> str | None: def format_folder_path(self, formatter: str) -> str: # Available keys: "albumartist", "title", "year", "bit_depth", "sampling_rate", - # "id", and "albumcomposer", + # "id", "albumcomposer", "container", "tracktotal", and "version". + # + # Two different editions of the same album can otherwise render to the + # same folder and get merged together -- "tracktotal" and "version" + # give a readable way to tell them apart without resorting to "id". none_str = "Unknown" info: dict[str, str | int | float] = { @@ -76,421 +82,206 @@ def format_folder_path(self, formatter: str) -> str: "title": clean_filename(self.album), "year": self.year, "container": self.info.container, + "tracktotal": self.tracktotal, + "version": clean_filename(self.version or "") or none_str, } return clean_filepath(formatter.format(**info)) @classmethod def from_qobuz(cls, resp: dict) -> AlbumMetadata: - album = resp.get("title", "Unknown Album") - tracktotal = resp.get("tracks_count", 1) - genre = [safe_get(resp, "genre", "name")] or resp.get("genre") or [] - genres = list(set(genre_clean.findall("/".join(genre)))) + album = (resp.get("title") or "Unknown Album").strip() + version = (resp.get("version") or "").strip() or None + # The edition is part of what the album *is* -- a standard and a deluxe + # release otherwise share a title, so they tag identically and collide + # in the same download folder. Fold it into the title (unless the title + # already says it) and keep it in `version` for tagging as well. + if version and version.lower() not in album.lower(): + album = f"{album} ({version})" + genre = safe_get(resp, "genre", "name") date = resp.get("release_date_original") or resp.get("release_date") - year = date[:4] if date is not None else "Unknown" - - _copyright = resp.get("copyright", "") - if artists := resp.get("artists"): albumartist = ", ".join(a["name"] for a in artists) else: - albumartist = typed(safe_get(resp, "artist", "name"), str) - - albumcomposer = typed(safe_get(resp, "composer", "name", default=""), str) - _label = resp.get("label") - if isinstance(_label, dict): - _label = _label["name"] - label = typed(_label or "", str) - description = typed(resp.get("description", ""), str) - disctotal = typed( - max( - track.get("media_number", 1) - for track in safe_get(resp, "tracks", "items", default=[{}]) # type: ignore - ) - or 1, - int, - ) - explicit = typed(resp.get("parental_warning", False), bool) - - # Non-embedded information - cover_urls = Covers.from_qobuz(resp) - + albumartist = safe_get(resp, "artist", "name") or "Unknown Artist" + label = resp.get("label") + if isinstance(label, dict): + label = label["name"] bit_depth = typed(resp.get("maximum_bit_depth", -1), int) sampling_rate = typed(resp.get("maximum_sampling_rate", -1.0), int | float) - quality = get_quality_id(bit_depth, sampling_rate) - # Make sure it is non-empty list - booklets = typed(resp.get("goodies", None) or None, list | None) - item_id = str(resp.get("qobuz_id")) - - if sampling_rate and bit_depth: - container = "FLAC" - else: - container = "MP3" info = AlbumInfo( - id=item_id, - quality=quality, - container=container, - label=label, - explicit=explicit, + id=str(resp.get("qobuz_id")), + quality=get_quality_id(bit_depth, sampling_rate), + container="FLAC" if sampling_rate and bit_depth else "MP3", + label=label or "", + explicit=bool(resp.get("parental_warning")), sampling_rate=sampling_rate, bit_depth=bit_depth, - booklets=booklets, + booklets=resp.get("goodies") or None, ) - return AlbumMetadata( + return cls( info, album, albumartist, - year, - genre=genres, - covers=cover_urls, - albumcomposer=albumcomposer, - comment=None, - compilation=None, - copyright=_copyright, + _year(date), + genre=list(set(genre_clean.findall(genre))) + if isinstance(genre, str) + else [], + covers=Covers.from_qobuz(resp), + tracktotal=resp.get("tracks_count", 1), + # "media_count" is authoritative and is present even when the album + # object is abbreviated. The track list is only embedded in a full + # album response, so deriving the disc count from it alone collapses + # to 1 for any album reached via a track (e.g. a single-track URL or + # `streamrip repair`), which then loses the "Disc N" subfolder. + disctotal=resp.get("media_count") + or max( + track.get("media_number", 1) + for track in safe_get(resp, "tracks", "items", default=[{}]) # type: ignore + ) + or 1, + albumcomposer=safe_get(resp, "composer", "name", default=""), + copyright=resp.get("copyright", ""), date=date, - description=description, - disctotal=disctotal, - encoder=None, - grouping=None, - lyrics=None, - purchase_date=None, - tracktotal=tracktotal, + description=resp.get("description") or "", + version=version, ) @classmethod - def from_deezer(cls, resp: dict) -> AlbumMetadata | None: - album = resp.get("title", "Unknown Album") - tracktotal = typed(resp.get("track_total", 0) or resp.get("nb_tracks", 0), int) - disctotal = typed(resp["tracks"][-1]["disk_number"], int) - genres = [typed(g["name"], str) for g in resp["genres"]["data"]] - - date = typed(resp["release_date"], str) - year = date[:4] - _copyright = None - description = None - albumartist = typed(safe_get(resp, "artist", "name"), str) - albumcomposer = None - label = resp.get("label") - booklets = None - explicit = typed( - resp.get("parental_warning", False) or resp.get("explicit_lyrics", False), - bool, - ) - - # not embedded - quality = 2 - bit_depth = 16 - sampling_rate = 44100 - container = "FLAC" - - cover_urls = Covers.from_deezer(resp) - item_id = str(resp["id"]) - + def from_deezer(cls, resp: dict) -> AlbumMetadata: + date = resp.get("release_date") info = AlbumInfo( - id=item_id, - quality=quality, - container=container, - label=label, - explicit=explicit, - sampling_rate=sampling_rate, - bit_depth=bit_depth, - booklets=booklets, + id=str(resp["id"]), + quality=2, + container="FLAC", + label=resp.get("label"), + explicit=bool(resp.get("parental_warning") or resp.get("explicit_lyrics")), + sampling_rate=44.1, + bit_depth=16, ) - return AlbumMetadata( + return cls( info, - album, - albumartist, - year, - genre=genres, - covers=cover_urls, - albumcomposer=albumcomposer, - comment=None, - compilation=None, - copyright=_copyright, + resp.get("title") or "Unknown Album", + ", ".join(deezer_artists(resp)), + _year(date), + genre=[ + g["name"] for g in safe_get(resp, "genres", "data", default=[]) or [] + ], + covers=Covers.from_deezer(resp), + tracktotal=resp.get("track_total") or resp.get("nb_tracks") or 0, + disctotal=resp["tracks"][-1]["disk_number"] if resp["tracks"] else 1, date=date, - description=description, - disctotal=disctotal, - encoder=None, - grouping=None, - lyrics=None, - purchase_date=None, - tracktotal=tracktotal, ) @classmethod - def from_soundcloud(cls, resp) -> AlbumMetadata: - track = resp - track_id = track["id"] - bit_depth, sampling_rate = None, None - explicit = typed( - safe_get(track, "publisher_metadata", "explicit", default=False), - bool, - ) - genre = typed(track.get("genre"), str | None) - genres = [genre] if genre is not None else [] - artist = typed(safe_get(track, "publisher_metadata", "artist"), str | None) - artist = artist or typed(track["user"]["username"], str) - albumartist = artist - date = typed(track.get("created_at"), str) - year = date[:4] - label = typed(track.get("label_name"), str | None) - description = typed(track.get("description"), str | None) - album_title = typed( - safe_get(track, "publisher_metadata", "album_title"), - str | None, - ) - album_title = album_title or "Unknown album" - copyright = typed(safe_get(track, "publisher_metadata", "p_line"), str | None) - tracktotal = 1 - disctotal = 1 - quality = 0 - covers = Covers.from_soundcloud(resp) - + def from_incomplete_deezer_track_resp(cls, resp: dict) -> AlbumMetadata: + """Album metadata from a track whose album response has no track list.""" + album = resp["album"] + date = album.get("release_date") info = AlbumInfo( - # There are no albums in soundcloud, so we just identify them by a track ID - id=track_id, - quality=quality, - container="MP3", - label=label, - explicit=explicit, - sampling_rate=sampling_rate, - bit_depth=bit_depth, - booklets=None, + id=str(album["id"]), + quality=2, + container="FLAC", + explicit=bool(resp.get("explicit_lyrics")), + sampling_rate=44.1, + bit_depth=16, ) - return AlbumMetadata( + return cls( info, - album_title, - albumartist, - year, - genre=genres, - covers=covers, - albumcomposer=None, - comment=None, - compilation=None, - copyright=copyright, + album.get("title") or "Unknown Album", + ", ".join(deezer_artists(resp)), + _year(date), + genre=[], + covers=Covers.from_deezer(album), + tracktotal=1, date=date, - description=description, - disctotal=disctotal, - encoder=None, - grouping=None, - lyrics=None, - purchase_date=None, - tracktotal=tracktotal, ) @classmethod - def from_tidal(cls, resp) -> AlbumMetadata | None: - """ - - Args: - ---- - resp: API response containing album metadata. - - Returns: AlbumMetadata instance if the album is streamable, otherwise None. - - - """ - streamable = resp.get("allowStreaming", False) - if not streamable: - return None - - item_id = str(resp["id"]) - album = typed(resp.get("title", "Unknown Album"), str) - tracktotal = typed(resp.get("numberOfTracks", 1), int) - # genre not returned by API - date = typed(resp.get("releaseDate"), str) - year = date[:4] - _copyright = typed(resp.get("copyright", ""), str) - - artists = typed(resp.get("artists", []), list) - albumartist = ", ".join(a["name"] for a in artists) - if not albumartist: - albumartist = typed(safe_get(resp, "artist", "name", default=""), str) - - disctotal = typed(resp.get("numberOfVolumes", 1), int) - # label not returned by API - - # non-embedded - explicit = typed(resp.get("explicit", False), bool) - covers = Covers.from_tidal(resp) - if covers is None: - covers = Covers() - - quality_map: dict[str, int] = { - "LOW": 0, - "HIGH": 1, - "LOSSLESS": 2, - "HI_RES": 3, - } - - tidal_quality = resp.get("audioQuality", "LOW") - quality = quality_map[tidal_quality] - if quality >= 2: - sampling_rate = 44100 - if quality == 3: - bit_depth = 24 - else: - bit_depth = 16 - else: - sampling_rate = None - bit_depth = None - + def from_soundcloud(cls, resp: dict) -> AlbumMetadata: + # SoundCloud has no albums: a track stands in for its own. + publisher = resp.get("publisher_metadata") or {} + date = resp.get("created_at") info = AlbumInfo( - id=item_id, - quality=quality, - container="MP4", - label=None, - explicit=explicit, - sampling_rate=sampling_rate, - bit_depth=bit_depth, - booklets=None, + id=str(resp["id"]), + quality=0, + container="MP3", + label=resp.get("label_name"), + explicit=bool(publisher.get("explicit")), ) - return AlbumMetadata( + return cls( info, - album, - albumartist, - year, - genre=[], - covers=covers, - albumcomposer=None, - comment=None, - compilation=None, - copyright=_copyright, + publisher.get("album_title") or "Unknown album", + publisher.get("artist") or resp["user"]["username"], + _year(date), + genre=[resp["genre"]] if resp.get("genre") else [], + covers=Covers.from_soundcloud(resp), + tracktotal=1, + copyright=publisher.get("p_line"), date=date, - description=None, - disctotal=disctotal, - encoder=None, - grouping=None, - lyrics=None, - purchase_date=None, - tracktotal=tracktotal, + description=resp.get("description"), ) @classmethod - def from_tidal_playlist_track_resp(cls, resp: dict) -> AlbumMetadata | None: - album_resp = resp["album"] - streamable = resp.get("allowStreaming", False) - if not streamable: + def from_tidal(cls, resp: dict) -> AlbumMetadata | None: + """None if the album can't be streamed.""" + if not resp.get("allowStreaming", False): return None - - item_id = str(resp["id"]) - album = typed(album_resp.get("title", "Unknown Album"), str) - tracktotal = 1 - # genre not returned by API - date = typed(resp.get("streamStartDate"), str | None) - if date is not None: - year = date[:4] + quality = tidal_quality_id(resp.get("audioQuality", "LOW")) + lossless = quality >= 2 + # The album only says LOSSLESS. For a hi-res one the client adds what + # its stream really is (bit depth, and sample rate in Hz). + stream = resp.get("streamQuality") or {} + if stream.get("bitDepth") and stream.get("sampleRate"): + quality, lossless = 3, True + bit_depth = stream["bitDepth"] + khz = stream["sampleRate"] / 1000 + sampling_rate = int(khz) if khz.is_integer() else khz else: - year = "Unknown Year" - - _copyright = typed(resp.get("copyright", ""), str) - artists = typed(resp.get("artists", []), list) - albumartist = ", ".join(a["name"] for a in artists) - if not albumartist: - albumartist = typed( - safe_get(resp, "artist", "name", default="Unknown Albumbartist"), str - ) - - disctotal = typed(resp.get("volumeNumber", 1), int) - # label not returned by API - - # non-embedded - explicit = typed(resp.get("explicit", False), bool) - covers = Covers.from_tidal(album_resp) - if covers is None: - covers = Covers() - - quality_map: dict[str, int] = { - "LOW": 0, - "HIGH": 1, - "LOSSLESS": 2, - "HI_RES": 3, - } - - tidal_quality = resp.get("audioQuality", "LOW") - quality = quality_map[tidal_quality] - if quality >= 2: - sampling_rate = 44100 - if quality == 3: - bit_depth = 24 - else: - bit_depth = 16 - else: - sampling_rate = None - bit_depth = None - + # Tidal doesn't say; this is its lossless tier. + bit_depth = (24 if quality == 3 else 16) if lossless else None + sampling_rate = 44.1 if lossless else None + date = resp.get("releaseDate") + artists = ", ".join(a["name"] for a in resp.get("artists") or []) info = AlbumInfo( - id=item_id, + id=str(resp["id"]), quality=quality, - container="MP4", - label=None, - explicit=explicit, + container="FLAC" if lossless else "AAC", + explicit=bool(resp.get("explicit")), sampling_rate=sampling_rate, bit_depth=bit_depth, - booklets=None, ) - return AlbumMetadata( + return cls( info, - album, - albumartist, - year, + resp.get("title") or "Unknown Album", + artists or safe_get(resp, "artist", "name", default="Unknown Artist"), + _year(date), genre=[], - covers=covers, - albumcomposer=None, - comment=None, - compilation=None, - copyright=_copyright, + covers=Covers.from_tidal(resp) or Covers(), + tracktotal=resp.get("numberOfTracks", 1), + disctotal=resp.get("numberOfVolumes", 1), + copyright=resp.get("copyright") or "", date=date, - description=None, - disctotal=disctotal, - encoder=None, - grouping=None, - lyrics=None, - purchase_date=None, - tracktotal=tracktotal, ) @classmethod - def from_incomplete_deezer_track_resp(cls, resp: dict) -> AlbumMetadata | None: - album_resp = resp["album"] - album_id = album_resp["id"] - album = album_resp["title"] - covers = Covers.from_deezer(album_resp) - date = album_resp["release_date"] - year = date[:4] - albumartist = ", ".join(a["name"] for a in resp["contributors"]) - explicit = resp.get("explicit_lyrics", False) - - info = AlbumInfo( - id=album_id, - quality=2, - container="MP4", - label=None, - explicit=explicit, - sampling_rate=None, - bit_depth=None, - booklets=None, - ) - return AlbumMetadata( - info, - album, - albumartist, - year, - genre=[], - covers=covers, - albumcomposer=None, - comment=None, - compilation=None, - copyright=None, - date=date, - description=None, - disctotal=1, - encoder=None, - grouping=None, - lyrics=None, - purchase_date=None, - tracktotal=1, + def from_tidal_playlist_track_resp(cls, resp: dict) -> AlbumMetadata | None: + """Album metadata from a track response. That only carries the album's + id, title and cover, so the rest is taken from the track itself. + """ + return cls.from_tidal( + resp["album"] + | { + "allowStreaming": resp.get("allowStreaming", False), + "audioQuality": resp.get("audioQuality", "LOW"), + "explicit": resp.get("explicit"), + "releaseDate": resp.get("streamStartDate"), + "copyright": resp.get("copyright"), + "artists": resp.get("artists"), + "artist": resp.get("artist"), + "numberOfVolumes": resp.get("volumeNumber", 1), + } ) @classmethod diff --git a/streamrip/metadata/playlist.py b/streamrip/metadata/playlist.py index 58a563c6..38751199 100644 --- a/streamrip/metadata/playlist.py +++ b/streamrip/metadata/playlist.py @@ -3,43 +3,11 @@ from .album import AlbumMetadata from .track import TrackMetadata -from .util import typed - -NON_STREAMABLE = "_non_streamable" -ORIGINAL_DOWNLOAD = "_original_download" -NOT_RESOLVED = "_not_resolved" +from .util import safe_get, typed logger = logging.getLogger("streamrip") -def get_soundcloud_id(resp: dict) -> str: - item_id = resp["id"] - if "media" not in resp: - return f"{item_id}|{NOT_RESOLVED}" - - if not resp["streamable"] or resp["policy"] == "BLOCK": - return f"{item_id}|{NON_STREAMABLE}" - - if resp["downloadable"] and resp["has_downloads_left"]: - return f"{item_id}|{ORIGINAL_DOWNLOAD}" - - url = None - for tc in resp["media"]["transcodings"]: - fmt = tc["format"] - if fmt["protocol"] == "hls" and fmt["mime_type"] == "audio/mpeg": - url = tc["url"] - break - - assert url is not None - return f"{item_id}|{url}" - - -def parse_soundcloud_id(item_id: str) -> tuple[str, str]: - info = item_id.split("|") - assert len(info) == 2 - return (info[0], info[1]) - - @dataclass(slots=True) class PlaylistMetadata: name: str @@ -50,14 +18,29 @@ def from_qobuz(cls, resp: dict): logger.debug(resp) name = typed(resp["name"], str) tracks = [] - - for i, track in enumerate(resp["tracks"]["items"]): - meta = TrackMetadata.from_qobuz( - AlbumMetadata.from_qobuz(track["album"]), - track, - ) + items = safe_get(resp, "tracks", "items", default=[]) or [] + + track_ids = [str(i) for i in resp.get("track_ids") or []] + if len(track_ids) > len(items): + # The inline track list is capped at one page (500 tracks) and has + # been empty since Qobuz's July 2026 API change, while track_ids + # lists every track. Each id is resolved by PendingPlaylistTrack. + return cls(name, track_ids) + + for i, track in enumerate(items): + try: + meta = TrackMetadata.from_qobuz( + AlbumMetadata.from_qobuz(track["album"]), + track, + ) + except Exception as e: + # One malformed entry should not sink the whole playlist. + logger.error(f"Error reading track {i + 1} in playlist {name}: {e}") + continue if meta is None: - logger.error(f"Track {i+1} in playlist {name} not available for stream") + logger.error( + f"Track {i + 1} in playlist {name} not available for stream" + ) continue tracks.append(meta) @@ -89,7 +72,15 @@ def from_soundcloud(cls, resp: dict): @classmethod def from_deezer(cls, resp: dict): name = typed(resp["title"], str) - tracks = [str(track["id"]) for track in resp["tracks"]] + ids = [str(track["id"]) for track in resp["tracks"]] + # Tracks you uploaded to Deezer yourself have negative ids and no album + # to tag them with; they're your own files anyway (upstream PR #832). + tracks = [i for i in ids if not i.startswith("-")] + if len(tracks) < len(ids): + logger.info( + f"{name}: skipping {len(ids) - len(tracks)} track(s) you uploaded " + "to Deezer yourself" + ) return cls(name, tracks) @classmethod diff --git a/streamrip/metadata/search_results.py b/streamrip/metadata/search_results.py index 4b3011b2..45ebc17e 100644 --- a/streamrip/metadata/search_results.py +++ b/streamrip/metadata/search_results.py @@ -33,7 +33,7 @@ def __str__(self): class ArtistSummary(Summary): id: str name: str - num_albums: str + num_albums: str | None def media_type(self): return "artist" @@ -42,6 +42,11 @@ def summarize(self) -> str: return clean(self.name) def preview(self) -> str: + # Not every source's artist search includes an album count (Tidal's + # doesn't at all), so don't print a made-up "Unknown Albums" -- just + # leave the line out rather than claim something we don't know. + if self.num_albums is None: + return f"ID: {self.id}" return f"{self.num_albums} Albums\n\nID: {self.id}" @classmethod @@ -58,7 +63,7 @@ def from_item(cls, item: dict): ) or "Unknown" ) - num_albums = item.get("albums_count") or "Unknown" + num_albums = item.get("albums_count") return cls(id, name, num_albums) @@ -268,7 +273,7 @@ def from_pages(cls, source: str, media_type: str, pages: list[dict]): return cls(results) def summaries(self) -> list[str]: - return [f"{i+1}. {r.summarize()}" for i, r in enumerate(self.results)] + return [f"{i + 1}. {r.summarize()}" for i, r in enumerate(self.results)] def get_choices(self, inds: tuple[int, ...] | int): if isinstance(inds, int): diff --git a/streamrip/metadata/tagger.py b/streamrip/metadata/tagger.py index aae8e2b5..82b51354 100644 --- a/streamrip/metadata/tagger.py +++ b/streamrip/metadata/tagger.py @@ -4,6 +4,7 @@ import aiofiles from mutagen import id3 +from mutagen.aiff import AIFF from mutagen.flac import FLAC, Picture from mutagen.id3 import ( APIC, # type: ignore @@ -18,88 +19,104 @@ FLAC_MAX_BLOCKSIZE = 16777215 # 16.7 MB -MP4_KEYS = ( - "\xa9nam", - "\xa9ART", - "\xa9alb", - r"aART", - "\xa9day", - "\xa9day", - "\xa9cmt", - "desc", - "purd", - "\xa9grp", - "\xa9gen", - "\xa9lyr", - "\xa9too", - "cprt", - "cpil", - "trkn", - "disk", - None, - None, - None, - "----:com.apple.iTunes:ISRC", -) - -MP3_KEYS = ( - id3.TIT2, # type: ignore - id3.TPE1, # type: ignore - id3.TALB, # type: ignore - id3.TPE2, # type: ignore - id3.TCOM, # type: ignore - id3.TYER, # type: ignore - id3.COMM, # type: ignore - id3.TT1, # type: ignore - id3.TT1, # type: ignore - id3.GP1, # type: ignore - id3.TCON, # type: ignore - id3.USLT, # type: ignore - id3.TEN, # type: ignore - id3.TCOP, # type: ignore - id3.TCMP, # type: ignore - id3.TRCK, # type: ignore - id3.TPOS, # type: ignore - None, - None, - None, - id3.TSRC, -) - -METADATA_TYPES = ( - "title", - "artist", - "album", - "albumartist", - "composer", - "year", - "comment", - "description", - "purchase_date", - "grouping", - "genre", - "lyrics", - "encoder", - "copyright", - "compilation", - "tracknumber", - "discnumber", - "tracktotal", - "disctotal", - "date", - "isrc", -) - - -FLAC_KEY = {v: v.upper() for v in METADATA_TYPES} -MP4_KEY = dict(zip(METADATA_TYPES, MP4_KEYS)) -MP3_KEY = dict(zip(METADATA_TYPES, MP3_KEYS)) +# Where each of streamrip's tag names (see _tag_values) goes in MP3/AIFF (ID3) +# and M4A files. FLAC's Vorbis comments take every name, upper-cased. +MP3_KEY = { + "title": id3.TIT2, + "artist": id3.TPE1, + "album": id3.TALB, + "albumartist": id3.TPE2, + "composer": id3.TCOM, + "year": id3.TYER, + "genre": id3.TCON, + "lyrics": id3.USLT, + "copyright": id3.TCOP, + "compilation": id3.TCMP, + "tracknumber": id3.TRCK, + "discnumber": id3.TPOS, + "isrc": id3.TSRC, +} + +MP4_KEY = { + "title": "\xa9nam", + "artist": "\xa9ART", + "album": "\xa9alb", + "albumartist": "aART", + "composer": "\xa9wrt", + "year": "\xa9day", + "description": "desc", + "genre": "\xa9gen", + "lyrics": "\xa9lyr", + "copyright": "cprt", + "compilation": "cpil", + "tracknumber": "trkn", + "discnumber": "disk", + "isrc": "----:com.apple.iTunes:ISRC", +} + + +def _tag_values(meta: TrackMetadata) -> dict: + """A track's tags by streamrip's own names, leaving out empty ones.""" + album = meta.album + values = { + "title": meta.title, + # Several artists are written as a real multi-valued tag, not one + # "A, B" string that players would have to split up again. + "artist": meta.artists + if meta.artists and len(meta.artists) > 1 + else meta.artist, + "album": album.album, + "albumartist": album.albumartist, + "composer": meta.composer, + "year": album.year, + "description": album.description, + "genre": album.get_genres(), + "lyrics": meta.lyrics, + "copyright": album.get_copyright(), + "compilation": album.compilation, + "tracknumber": meta.tracknumber, + "discnumber": meta.discnumber, + "tracktotal": album.tracktotal, + "disctotal": album.disctotal, + "date": album.date, + "isrc": meta.isrc, + "version": album.version, + } + return {k: v for k, v in values.items() if v is not None and v != ""} + + +def _flac_value(name: str, value): + if name in ("tracknumber", "discnumber", "tracktotal", "disctotal"): + return f"{int(value):02}" + return value if isinstance(value, list) else str(value) + + +def _mp3_value(name: str, value, values: dict): + total = {"tracknumber": "tracktotal", "discnumber": "disctotal"}.get(name) + if total in values: + return f"{value}/{values[total]}" + # save_audio() writes ID3v2.3, which joins a list of artists with "/". + return value if isinstance(value, list) else str(value) + + +def _mp4_value(name: str, value, values: dict): + if name == "tracknumber": + return [(value, values.get("tracktotal", 0))] + if name == "discnumber": + return [(value, values.get("disctotal", 0))] + if name == "isrc": + # A freeform atom, which mutagen wants as bytes. + return value.encode("utf-8") + if name == "compilation": + return True # cpil is a flag, and only ever set for compilations + return value if isinstance(value, list) else str(value) class Container(Enum): FLAC = 1 AAC = 2 MP3 = 3 + AIFF = 4 def get_mutagen_class(self, path: str): if self == Container.FLAC: @@ -111,97 +128,37 @@ def get_mutagen_class(self, path: str): return ID3(path) except ID3NoHeaderError: return ID3() + elif self == Container.AIFF: + audio = AIFF(path) + if audio.tags is None: + audio.add_tags() + return audio.tags # unreachable return {} - def get_tag_pairs(self, meta) -> list[tuple]: + def get_tag_pairs(self, meta, exclude=()) -> list[tuple]: + # [metadata] exclude lists streamrip's own tag names ("genre", + # "albumartist", ...); it used to be ignored entirely (upstream #850). + values = {k: v for k, v in _tag_values(meta).items() if k not in exclude} if self == Container.FLAC: - return self._tag_flac(meta) - elif self == Container.MP3: - return self._tag_mp3(meta) - elif self == Container.AAC: - return self._tag_mp4(meta) - # unreachable + return [(k.upper(), _flac_value(k, v)) for k, v in values.items()] + if self in (Container.MP3, Container.AIFF): + return [ + ( + frame.__name__, + frame(encoding=3, text=_mp3_value(k, values[k], values)), + ) + for k, frame in MP3_KEY.items() + if k in values + ] + if self == Container.AAC: + return [ + (key, _mp4_value(k, values[k], values)) + for k, key in MP4_KEY.items() + if k in values + ] return [] - def _tag_flac(self, meta: TrackMetadata) -> list[tuple]: - out = [] - for k, v in FLAC_KEY.items(): - tag = self._attr_from_meta(meta, k) - if tag: - if k in { - "tracknumber", - "discnumber", - "tracktotal", - "disctotal", - }: - tag = f"{int(tag):02}" - - out.append((v, str(tag))) - return out - - def _tag_mp3(self, meta: TrackMetadata): - out = [] - for k, v in MP3_KEY.items(): - if k == "tracknumber": - text = f"{meta.tracknumber}/{meta.album.tracktotal}" - elif k == "discnumber": - text = f"{meta.discnumber}/{meta.album.disctotal}" - else: - text = self._attr_from_meta(meta, k) - - if text is not None and v is not None: - out.append((v.__name__, v(encoding=3, text=text))) - return out - - def _tag_mp4(self, meta: TrackMetadata): - out = [] - for k, v in MP4_KEY.items(): - if k == "tracknumber": - text = [(meta.tracknumber, meta.album.tracktotal)] - elif k == "discnumber": - text = [(meta.discnumber, meta.album.disctotal)] - elif k == "isrc" and meta.isrc is not None: - # because ISRC is an mp4 freeform value (not supported natively) - # we have to pass in the actual bytes to mutagen - # See mutagen.MP4Tags.__render_freeform - text = meta.isrc.encode("utf-8") - else: - text = self._attr_from_meta(meta, k) - - if v is not None and text is not None: - out.append((v, text)) - return out - - def _attr_from_meta(self, meta: TrackMetadata, attr: str) -> str | None: - # TODO: verify this works - in_trackmetadata = { - "title", - "album", - "artist", - "tracknumber", - "discnumber", - "composer", - "isrc", - "lyrics", - } - if attr in in_trackmetadata: - if attr == "album": - return meta.album.album - val = getattr(meta, attr) - if val is None: - return None - return str(val) - else: - if attr == "genre": - return meta.album.get_genres() - elif attr == "copyright": - return meta.album.get_copyright() - val = getattr(meta.album, attr) - if val is None: - return None - return str(val) - def tag_audio(self, audio, tags: list[tuple]): for k, v in tags: audio[k] = v @@ -217,7 +174,7 @@ async def embed_cover(self, audio, cover_path): async with aiofiles.open(cover_path, "rb") as img: cover.data = await img.read() audio.add_picture(cover) - elif self == Container.MP3: + elif self in (Container.MP3, Container.AIFF): cover = APIC() cover.type = 3 cover.mime = "image/jpeg" @@ -230,29 +187,43 @@ async def embed_cover(self, audio, cover_path): audio["covr"] = [cover] def save_audio(self, audio, path): - if self == Container.FLAC: - audio.save() - elif self == Container.AAC: + if self in (Container.FLAC, Container.AAC): audio.save() elif self == Container.MP3: - audio.save(path, "v2_version=3") - - -async def tag_file(path: str, meta: TrackMetadata, cover_path: str | None): + # ID3v2.3 for the widest player support. This used to pass the + # string "v2_version=3" as the v1 argument, so it never applied. + audio.update_to_v23() + audio.save(path, v2_version=3) + elif self == Container.AIFF: + audio.save(path) + + +EXTENSION_CONTAINERS = { + "flac": Container.FLAC, + "m4a": Container.AAC, + "mp3": Container.MP3, + "aiff": Container.AIFF, + "aif": Container.AIFF, +} +# Extensions tag_file() can write to (others keep what ffmpeg copied). +TAGGABLE_EXTENSIONS = frozenset(EXTENSION_CONTAINERS) + + +async def tag_file( + path: str, + meta: TrackMetadata, + cover_path: str | None, + exclude: list[str] | tuple[str, ...] = (), +): ext = path.split(".")[-1].lower() - if ext == "flac": - container = Container.FLAC - elif ext == "m4a": - container = Container.AAC - elif ext == "mp3": - container = Container.MP3 - else: + container = EXTENSION_CONTAINERS.get(ext) + if container is None: raise Exception(f"Invalid extension {ext}") audio = container.get_mutagen_class(path) - tags = container.get_tag_pairs(meta) + tags = container.get_tag_pairs(meta, exclude) logger.debug("Tagging with %s", tags) container.tag_audio(audio, tags) - if cover_path is not None: + if cover_path is not None and "cover" not in (exclude or ()): await container.embed_cover(audio, cover_path) container.save_audio(audio, path) diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index bea7a9b0..aab78f78 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -2,10 +2,9 @@ import logging from dataclasses import dataclass -from typing import Optional from .album import AlbumMetadata -from .util import safe_get, typed +from .util import deezer_artists, safe_get logger = logging.getLogger("streamrip") @@ -13,12 +12,7 @@ @dataclass(slots=True) class TrackInfo: id: str - quality: int - - bit_depth: Optional[int] = None explicit: bool = False - sampling_rate: Optional[int | float] = None - work: Optional[str] = None @dataclass(slots=True) @@ -33,186 +27,87 @@ class TrackMetadata: composer: str | None isrc: str | None = None lyrics: str | None = "" + # Individual artist names, when the source distinguishes them (Tidal, + # Deezer). `artist` above stays a single display string (joined with + # ", ") for filenames and templates; the tagger writes this list as a + # real multi-valued ARTIST tag instead of baking the join into one + # string, which is what let players mis-split "A, B" back apart. + artists: list[str] | None = None @classmethod def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None: - title = typed(resp["title"].strip(), str) - isrc = typed(resp["isrc"], str) - streamable = typed(resp.get("streamable", False), bool) - - if not streamable: + if not resp.get("streamable", False): return None - - version = typed(resp.get("version"), str | None) - work = typed(resp.get("work"), str | None) - if version is not None and version not in title: + title = resp["title"].strip() + version, work = resp.get("version"), resp.get("work") + if version and version not in title: title = f"{title} ({version})" - if work is not None and work not in title: + if work and work not in title: title = f"{work}: {title}" - - composer = typed(resp.get("composer", {}).get("name"), str | None) - tracknumber = typed(resp.get("track_number", 1), int) - discnumber = typed(resp.get("media_number", 1), int) - artist = typed( - safe_get( - resp, - "performer", - "name", - ), - str, - ) - track_id = str(resp["id"]) - bit_depth = typed(resp.get("maximum_bit_depth"), int | None) - sampling_rate = typed(resp.get("maximum_sampling_rate"), int | float | None) - # Is the info included? - explicit = False - - info = TrackInfo( - id=track_id, - quality=album.info.quality, - bit_depth=bit_depth, - explicit=explicit, - sampling_rate=sampling_rate, - work=work, - ) return cls( - info=info, - title=title, - album=album, - artist=artist, - tracknumber=tracknumber, - discnumber=discnumber, - composer=composer, - isrc=isrc, + TrackInfo(str(resp["id"]), bool(resp.get("parental_warning"))), + title, + album, + # "performer" is missing on some tracks (upstream #668); fall back + # to the album artist rather than failing the whole track. + safe_get(resp, "performer", "name") + or safe_get(resp, "album", "artist", "name") + or album.albumartist, + resp.get("track_number", 1), + resp.get("media_number", 1), + safe_get(resp, "composer", "name"), + isrc=resp.get("isrc"), ) @classmethod - def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None: - track_id = str(resp["id"]) - isrc = typed(resp["isrc"], str) - bit_depth = 16 - sampling_rate = 44.1 - explicit = typed(resp["explicit_lyrics"], bool) - work = None - title = typed(resp["title"], str) - artist = typed(resp["artist"]["name"], str) - tracknumber = typed(resp["track_position"], int) - discnumber = typed(resp["disk_number"], int) - composer = None - info = TrackInfo( - id=track_id, - quality=album.info.quality, - bit_depth=bit_depth, - explicit=explicit, - sampling_rate=sampling_rate, - work=work, - ) + def from_deezer(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata: + artists = deezer_artists(resp) return cls( - info=info, - title=title, - album=album, - artist=artist, - tracknumber=tracknumber, - discnumber=discnumber, - composer=composer, - isrc=isrc, + TrackInfo(str(resp["id"]), bool(resp.get("explicit_lyrics"))), + resp["title"], + album, + ", ".join(artists), + resp["track_position"], + resp["disk_number"], + None, + isrc=resp.get("isrc"), + lyrics=resp.get("lyrics", ""), + artists=artists, ) @classmethod def from_soundcloud(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata: - track = resp - track_id = track["id"] - isrc = typed(safe_get(track, "publisher_metadata", "isrc"), str | None) - bit_depth, sampling_rate = None, None - explicit = typed( - safe_get(track, "publisher_metadata", "explicit", default=False), - bool, - ) - - title = typed(track["title"].strip(), str) - artist = typed(track["user"]["username"], str) - tracknumber = 1 - - info = TrackInfo( - id=track_id, - quality=album.info.quality, - bit_depth=bit_depth, - explicit=explicit, - sampling_rate=sampling_rate, - work=None, - ) + publisher = resp.get("publisher_metadata") or {} return cls( - info=info, - title=title, - album=album, - artist=artist, - tracknumber=tracknumber, - discnumber=0, - composer=None, - isrc=isrc, + TrackInfo(str(resp["id"]), bool(publisher.get("explicit"))), + resp["title"].strip(), + album, + resp["user"]["username"], + 1, + 1, + None, + isrc=publisher.get("isrc"), ) @classmethod - def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata: - title = typed(track["title"], str).strip() - item_id = str(track["id"]) - isrc = typed(track["isrc"], str) - version = track.get("version") - explicit = track.get("explicit", False) - if version: + def from_tidal(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata: + title = resp["title"].strip() + if version := resp.get("version"): title = f"{title} ({version})" - - tracknumber = typed(track.get("trackNumber", 1), int) - discnumber = typed(track.get("volumeNumber", 1), int) - - artists = track.get("artists") - if len(artists) > 0: - artist = ", ".join(a["name"] for a in artists) - else: - artist = track["artist"]["name"] - - lyrics = track.get("lyrics", "") - - quality_map: dict[str, int] = { - "LOW": 0, - "HIGH": 1, - "LOSSLESS": 2, - "HI_RES": 3, - } - - tidal_quality = track.get("audioQuality") - if tidal_quality is not None: - quality = quality_map[tidal_quality] - else: - quality = 0 - - if quality >= 2: - sampling_rate = 44100 - if quality == 3: - bit_depth = 24 - else: - bit_depth = 16 - else: - sampling_rate = bit_depth = None - - info = TrackInfo( - id=item_id, - quality=quality, - bit_depth=bit_depth, - explicit=explicit, - sampling_rate=sampling_rate, - work=None, - ) + artists = [a["name"] for a in resp.get("artists") or []] or [ + resp["artist"]["name"] + ] return cls( - info=info, - title=title, - album=album, - artist=artist, - tracknumber=tracknumber, - discnumber=discnumber, - composer=None, - isrc=isrc, - lyrics=lyrics, + TrackInfo(str(resp["id"]), bool(resp.get("explicit"))), + title, + album, + ", ".join(artists), + resp.get("trackNumber", 1), + resp.get("volumeNumber", 1), + None, + isrc=resp.get("isrc"), + lyrics=resp.get("lyrics", ""), + artists=artists, ) @classmethod @@ -225,17 +120,21 @@ def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata | None: return cls.from_soundcloud(album, resp) if source == "deezer": return cls.from_deezer(album, resp) - raise Exception + raise Exception(f"Invalid source {source}") def format_track_path(self, format_string: str) -> str: - # Available keys: "tracknumber", "artist", "albumartist", "composer", "title", - # and "explicit", "albumcomposer" + # Available keys: "id", "tracknumber", "discnumber", "artist", "album", + # "albumartist", "composer", "title", "explicit", "albumcomposer" none_text = "Unknown" info = { "id": self.info.id, "title": self.title, "tracknumber": self.tracknumber, + "discnumber": self.discnumber, "artist": self.artist, + "album": self.album.album, + # Alias requested upstream (PR #826). + "albumtitle": self.album.album, "albumartist": self.album.albumartist, "albumcomposer": self.album.albumcomposer or none_text, "composer": self.composer or none_text, diff --git a/streamrip/metadata/util.py b/streamrip/metadata/util.py index 2026b008..712658ca 100644 --- a/streamrip/metadata/util.py +++ b/streamrip/metadata/util.py @@ -3,12 +3,23 @@ def get_album_track_ids(source: str, resp) -> list[str]: + if source == "qobuz" and "tracks" not in resp: + # Qobuz's album/get stopped inlining "tracks" (July 2026); the client + # requests extra=track_ids instead. + return resp["track_ids"] tracklist = resp["tracks"] if source == "qobuz": tracklist = tracklist["items"] return [track["id"] for track in tracklist] +def deezer_artists(resp: dict) -> list[str]: + """The credited artists of a Deezer album or track response.""" + contributors = resp.get("contributors") or [] + names = [c["name"] for c in contributors if c.get("type") == "artist"] + return names or [safe_get(resp, "artist", "name", default="Unknown Artist")] + + def safe_get(dictionary, *keys, default=None): return functools.reduce( lambda d, key: d.get(key, default) if isinstance(d, dict) else default, @@ -25,18 +36,41 @@ def typed(thing, expected_type: Type[T]) -> T: return thing +# Tidal's audioQuality values mapped to streamrip's quality ids. HI_RES was MQA +# (retired by Tidal); HI_RES_LOSSLESS is 24-bit FLAC. +TIDAL_QUALITY_IDS: dict[str, int] = { + "LOW": 0, + "HIGH": 1, + "LOSSLESS": 2, + "HI_RES": 3, + "HI_RES_LOSSLESS": 3, +} + + +def tidal_quality_id(audio_quality: str | None) -> int: + """Quality id for a Tidal audioQuality value, tolerating unknown ones.""" + if audio_quality is None: + return 0 + return TIDAL_QUALITY_IDS.get(audio_quality, 2) + + +def format_quality( + container: str, + bit_depth: Optional[int], + sampling_rate: Optional[int | float], +) -> str: + """Quality label, e.g. "[FLAC 24B-96kHz]".""" + # Qobuz defaults missing values to -1/-1.0, which is truthy. + if bit_depth and bit_depth > 0 and sampling_rate and sampling_rate > 0: + return f"[{container} {bit_depth}B-{sampling_rate:g}kHz]" + return f"[{container}]" + + def get_quality_id( bit_depth: Optional[int], sampling_rate: Optional[int | float], ) -> int: - """Get the universal quality id from bit depth and sampling rate. - - :param bit_depth: - :type bit_depth: Optional[int] - :param sampling_rate: In kHz - :type sampling_rate: Optional[int] - """ - # XXX: Should `0` quality be supported? + """streamrip's quality id (1-4) for a bit depth and a sampling rate in kHz.""" if bit_depth is None or sampling_rate is None: # is lossy return 1 diff --git a/streamrip/progress.py b/streamrip/progress.py index 803a4d7d..a035100a 100644 --- a/streamrip/progress.py +++ b/streamrip/progress.py @@ -6,6 +6,7 @@ from rich.progress import ( BarColumn, Progress, + SpinnerColumn, TextColumn, TimeRemainingColumn, TransferSpeedColumn, @@ -19,8 +20,14 @@ class ProgressManager: def __init__(self): self.started = False - self.progress = Progress(console=console) + # Its own Progress, not a task in the download one: that one's columns + # (bar, transfer speed, ETA) don't mean anything for "still fetching + # metadata" -- this is just a spinner and a line of text. + self.resolve_progress = Progress( + SpinnerColumn(), TextColumn("[cyan]{task.description}"), console=console + ) self.progress = Progress( + SpinnerColumn(), TextColumn("[cyan]{task.description}"), BarColumn(bar_width=None), "[progress.percentage]{task.percentage:>3.1f}%", @@ -31,44 +38,78 @@ def __init__(self): console=console, ) - self.task_titles = [] + # Keyed by id(), not title text: two releases can share a title. + self.task_titles: dict[int, str] = {} self.prefix = Text.assemble(("Downloading ", "bold cyan"), overflow="ellipsis") self._text_cache = self.gen_title_text() - self.live = Live(Group(self._text_cache, self.progress), refresh_per_second=10) + # Explicit console=console: otherwise Live draws through Rich's own + # default console, a different object than the one console.log() and + # the logger's RichHandler print through, so neither knows the other + # is using the terminal -- each print corrupts the other's region + # instead of Rich's usual "pause the live area, print above it". + self.live = Live(self._group(), console=console, refresh_per_second=10) - def get_callback(self, total: int, desc: str): + def _group(self) -> Group: + return Group(self.get_title_text(), self.resolve_progress, self.progress) + + def _ensure_started(self): if not self.started: self.live.start() self.started = True + def _refresh(self): + if self.started: + self.live.update(self._group()) + + def get_callback(self, total: int, desc: str): + self._ensure_started() + task = self.progress.add_task(f"[cyan]{desc}", total=total) def _callback_update(x: int): self.progress.update(task, advance=x) - self.live.update(Group(self.get_title_text(), self.progress)) + self._refresh() def _callback_done(): self.progress.update(task, visible=False) return Handle(_callback_update, _callback_done) + def get_resolve_callback(self, desc: str): + self._ensure_started() + + task = self.resolve_progress.add_task(f"[cyan]{desc}") + self._refresh() + + def _done(): + self.resolve_progress.update(task, visible=False) + + return Handle(lambda _: None, _done) + def cleanup(self): if self.started: self.live.stop() - def add_title(self, title: str): - self.task_titles.append(title.strip()) + def add_title(self, key: int, title: str): + self._ensure_started() + self.task_titles[key] = title.strip() self._text_cache = self.gen_title_text() + self._refresh() - def remove_title(self, title: str): - self.task_titles.remove(title.strip()) + def remove_title(self, key: int): + self.task_titles.pop(key, None) self._text_cache = self.gen_title_text() + self._refresh() def gen_title_text(self) -> Rule: - titles = ", ".join(self.task_titles[:3]) - if len(self.task_titles) > 3: - titles += "..." - t = self.prefix + Text(titles) + # One name, not several joined by commas: two albums on one line read + # as one confusing thing, not two. Dict order is insertion order, so + # this is the most recently started album/playlist still active. + titles = list(self.task_titles.values()) + shown = titles[-1] if titles else "" + if len(titles) > 1: + shown += f" (+{len(titles) - 1} more)" + t = self.prefix + Text(shown) return Rule(t) def get_title_text(self) -> Rule: @@ -98,14 +139,25 @@ def get_progress_callback(enabled: bool, total: int, desc: str) -> Handle: return _p.get_callback(total, desc) -def add_title(title: str): +def get_resolve_callback(enabled: bool, desc: str) -> Handle: global _p - _p.add_title(title) + if not enabled: + return Handle(lambda _: None, lambda: None) + return _p.get_resolve_callback(desc) -def remove_title(title: str): +def add_title(key: int, title: str, enabled: bool = True): + if not enabled: + return + global _p + _p.add_title(key, title) + + +def remove_title(key: int, enabled: bool = True): + if not enabled: + return global _p - _p.remove_title(title) + _p.remove_title(key) def clear_progress(): diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 2b3f532e..5d102c49 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -2,7 +2,9 @@ import json import logging import os +import re import shutil +import signal import subprocess from functools import wraps from typing import Any @@ -17,16 +19,51 @@ from rich.traceback import install from .. import __version__, db +from ..client import new_session from ..config import DEFAULT_CONFIG_PATH, Config, OutdatedConfigError, set_user_defaults from ..console import console -from ..utils.ssl_utils import get_aiohttp_connector_kwargs +from ..utils.ssl_utils import print_ssl_error_help from .main import Main +logger = logging.getLogger("streamrip") + + +# Where this build of streamrip comes from, for update checks and advice. +REPOSITORY = "Stensel8/streamrip" +UPGRADE_COMMAND = f"pip install --upgrade git+https://github.com/{REPOSITORY}.git" + def coro(f): @wraps(f) def wrapper(*args, **kwargs): - return asyncio.run(f(*args, **kwargs)) + async def run(): + # Ctrl-C used to be ignored until whatever was in flight finished, + # so people force-killed streamrip -- which skips the cleanup in + # Main.__aexit__ and leaves __artwork directories behind. Cancel + # the task instead so everything unwinds (upstream PR #1025). + task = asyncio.current_task() + loop = asyncio.get_running_loop() + + def stop(): + console.print("\n[yellow]Stopping... (Ctrl-C again to force)") + loop.remove_signal_handler(signal.SIGINT) + if task is not None: + task.cancel() + + try: + loop.add_signal_handler(signal.SIGINT, stop) + except NotImplementedError, RuntimeError: + pass # Windows: default KeyboardInterrupt behaviour + + return await f(*args, **kwargs) + + try: + return asyncio.run(run()) + except asyncio.CancelledError, KeyboardInterrupt: + console.print("[yellow]Stopped.") + except aiohttp.ClientConnectorCertificateError as e: + console.print(f"[red]SSL Certificate verification error: {e}[/red]") + print_ssl_error_help() return wrapper @@ -65,7 +102,11 @@ def wrapper(*args, **kwargs): @click.option( "-c", "--codec", - help="Convert the downloaded files to an audio codec (ALAC, FLAC, MP3, AAC, or OGG)", + help="Convert the downloaded files to an audio codec " + "(ALAC, FLAC, AIFF, MP3, AAC, OGG, or OPUS)", + type=click.Choice( + ["ALAC", "FLAC", "AIFF", "MP3", "AAC", "OGG", "OPUS"], case_sensitive=False + ), ) @click.option( "--no-progress", @@ -95,7 +136,7 @@ def rip( level="INFO", format="%(message)s", datefmt="[%X]", - handlers=[RichHandler()], + handlers=[RichHandler(console=console)], ) logger = logging.getLogger("streamrip") if verbose: @@ -133,7 +174,7 @@ def rip( except Exception as e: console.print( f"Error loading config from [bold cyan]{config_path}[/bold cyan]: {e}\n" - "Try running [bold]rip config reset[/bold]", + "Try running [bold]streamrip config reset[/bold]", ) ctx.obj["config"] = None return @@ -148,11 +189,9 @@ def rip( c.session.qobuz.quality = quality c.session.tidal.quality = quality c.session.deezer.quality = quality - c.session.soundcloud.quality = quality if codec is not None: c.session.conversion.enabled = True - assert codec.upper() in ("ALAC", "FLAC", "OGG", "MP3", "AAC") c.session.conversion.codec = codec.upper() if no_progress: @@ -173,42 +212,33 @@ async def url(ctx, urls): if ctx.obj["config"] is None: return - try: - with ctx.obj["config"] as cfg: - cfg: Config - updates = cfg.session.misc.check_for_updates - if updates: - # Run in background - version_coro = asyncio.create_task( - latest_streamrip_version( - verify_ssl=cfg.session.downloads.verify_ssl - ) - ) - else: - version_coro = None - - async with Main(cfg) as main: - await main.add_all(urls) - await main.resolve() - await main.rip() + with ctx.obj["config"] as cfg: + cfg: Config + updates = cfg.session.misc.check_for_updates + if updates: + # Run in background + version_coro = asyncio.create_task( + latest_streamrip_version(verify_ssl=cfg.session.downloads.verify_ssl) + ) + else: + version_coro = None - if version_coro is not None: - latest_version, notes = await version_coro - if latest_version != __version__: - console.print( - f"\n[green]A new version of streamrip [cyan]v{latest_version}[/cyan]" - " is available! Run [white][bold]pip3 install streamrip --upgrade[/bold][/white]" - " to update.[/green]\n" - ) + async with Main(cfg) as main: + await main.add_all(urls) + await main.resolve() + await main.rip() + if version_coro is not None: + latest_version, notes = await version_coro + if is_newer_version(latest_version): + console.print( + f"\n[green]A new version of streamrip [cyan]v{latest_version}" + f"[/cyan] is available! Run [white][bold]{UPGRADE_COMMAND}" + "[/bold][/white] to update.[/green]\n" + ) + if notes: console.print(Markdown(notes)) - except aiohttp.ClientConnectorCertificateError as e: - from ..utils.ssl_utils import print_ssl_error_help - - console.print(f"[red]SSL Certificate verification error: {e}[/red]") - print_ssl_error_help() - @rip.command() @click.argument( @@ -223,45 +253,43 @@ async def file(ctx, path): Example usage: - rip file urls.txt + streamrip file urls.txt """ - try: - with ctx.obj["config"] as cfg: - async with Main(cfg) as main: - async with aiofiles.open(path, "r") as f: - content = await f.read() - try: - items: Any = json.loads(content) - loaded = True - except json.JSONDecodeError: - items = content.split() - loaded = False - if loaded: - console.print( - f"Detected json file. Loading [yellow]{len(items)}[/yellow] items" - ) - await main.add_all_by_id( - [(i["source"], i["media_type"], i["id"]) for i in items] - ) - else: - s = set(items) - if len(s) < len(items): - console.print( - f"Found [orange]{len(items)-len(s)}[/orange] repeated URLs!" - ) - items = list(s) + if ctx.obj["config"] is None: + return + with ctx.obj["config"] as cfg: + async with Main(cfg) as main: + async with aiofiles.open(path, "r") as f: + content = await f.read() + try: + items: Any = json.loads(content) + loaded = True + except json.JSONDecodeError: + items = content.split() + loaded = False + if loaded: + console.print( + f"Detected json file. Loading [yellow]{len(items)}[/yellow] items" + ) + await main.add_all_by_id( + [(i["source"], i["media_type"], i["id"]) for i in items] + ) + else: + # dict, not set: keeps the file's order. + unique = list(dict.fromkeys(items)) + if len(unique) < len(items): console.print( - f"Detected list of urls. Loading [yellow]{len(items)}[/yellow] items" + f"Found [yellow]{len(items) - len(unique)}[/yellow] " + "repeated URLs!" ) - await main.add_all(items) - - await main.resolve() - await main.rip() - except aiohttp.ClientConnectorCertificateError as e: - from ..utils.ssl_utils import print_ssl_error_help + items = unique + console.print( + f"Detected list of urls. Loading [yellow]{len(items)}[/yellow] items" + ) + await main.add_all(items) - console.print(f"[red]SSL Certificate verification error: {e}[/red]") - print_ssl_error_help() + await main.resolve() + await main.rip() @rip.group() @@ -320,45 +348,219 @@ def database(): @database.command("browse") -@click.argument("table") +@click.argument( + "table", type=click.Choice(["downloads", "failed"], case_sensitive=False) +) @click.pass_context def database_browse(ctx, table): """Browse the contents of a table. Available tables: - * Downloads + * downloads - * Failed + * failed """ from rich.table import Table - cfg: Config = ctx.obj["config"] + cfg: Config | None = ctx.obj["config"] + if cfg is None: + return if table.lower() == "downloads": - downloads = db.Downloads(cfg.session.database.downloads_path) - t = Table(title="Downloads database") - t.add_column("Row") - t.add_column("ID") - for i, row in enumerate(downloads.all()): - t.add_row(f"{i:02}", *row) - console.print(t) - - elif table.lower() == "failed": - failed = db.Failed(cfg.session.database.failed_downloads_path) - t = Table(title="Failed downloads database") - t.add_column("Source") - t.add_column("Media Type") - t.add_column("ID") - for i, row in enumerate(failed.all()): - t.add_row(f"{i:02}", *row) - console.print(t) - + t = Table("Row", "ID", title="Downloads database") + rows = db.Downloads(cfg.session.database.downloads_path).all() else: + t = Table( + "Row", "Source", "Media Type", "ID", title="Failed downloads database" + ) + rows = db.Failed(cfg.session.database.failed_downloads_path).all() + for i, row in enumerate(rows): + t.add_row(f"{i:02}", *row) + console.print(t) + + +@database.command("clear") +@click.argument( + "table", + type=click.Choice(["downloads", "failed", "all"], case_sensitive=False), +) +@click.option("-y", "--yes", help="Don't ask for confirmation.", is_flag=True) +@click.pass_context +def database_clear(ctx, table, yes): + """Forget what was downloaded, so it is downloaded again. + + streamrip skips every track in the downloads database, even after its file + was deleted. Clear the database to download those tracks again. + + Tables: + + * downloads: the tracks streamrip has downloaded + + * failed: the failures kept for `streamrip repair` + + * all: both + """ + cfg: Config | None = ctx.obj["config"] + if cfg is None: + return + + tables = [] + if table in ("downloads", "all"): + tables.append( + ("downloaded track(s)", db.Downloads(cfg.session.database.downloads_path)) + ) + if table in ("failed", "all"): + tables.append( + ( + "failed download(s)", + db.Failed(cfg.session.database.failed_downloads_path), + ) + ) + + counts = [len(t.all()) for _, t in tables] + if sum(counts) == 0: + console.print("[green]Nothing to clear.") + return + + summary = " and ".join( + f"[yellow]{n}[/yellow] {label}" for n, (label, _) in zip(counts, tables) + ) + if not yes and not Confirm.ask(f"Forget {summary}? They will be downloaded again."): + console.print("[green]Clear aborted") + return + + for _, t in tables: + t.clear() + console.print(f"[green]Cleared {summary}.") + + +async def _albums_for(main, failed_items): + """Map failed tracks onto the albums that contain them. + + Returns (album targets, items to retry as they are). Anything whose album + cannot be determined is passed through untouched rather than dropped. + """ + targets: list[tuple[str, str, str]] = [] + unresolved: list[tuple[str, str, str]] = [] + seen: set[tuple[str, str]] = set() + + for source, media_type, item_id in failed_items: + if media_type != "track": + targets.append((source, media_type, item_id)) + continue + try: + client = await main.get_logged_in_client(source) + resp = await client.get_metadata(item_id, "track") + album_id = str((resp.get("album") or {}).get("id") or "") + except Exception as e: + logger.debug("Could not find the album for %s: %s", item_id, e) + album_id = "" + + if not album_id: + unresolved.append((source, media_type, item_id)) + continue + if (source, album_id) not in seen: + seen.add((source, album_id)) + targets.append((source, "album", album_id)) + + return targets, unresolved + + +@rip.command() +@click.option("-y", "--yes", help="Don't ask for confirmation.", is_flag=True) +@click.option( + "--flat", + help="Put repaired tracks straight in the download folder instead of " + "their album folder.", + is_flag=True, +) +@click.pass_context +@coro +async def repair(ctx, yes, flat): + """Retry downloads that previously failed. + + Reads the failed downloads database, retries each item, and clears it + from the failed database on success. Items that fail again stay logged + so they can be retried later. + + Failed tracks are retried individually, but are placed in their album's + folder so they rejoin the album they were originally missing from. Pass + --flat to put them in the download folder instead. + """ + if ctx.obj["config"] is None: + return + + with ctx.obj["config"] as cfg: + cfg: Config + # A repaired track is nearly always a track missing from an album that + # was otherwise downloaded, so it needs to land in that album's folder + # rather than loose in the download root. This only touches the + # in-memory session copy, so config.toml is left alone. + if not flat: + cfg.session.filepaths.add_singles_to_folder = True + failed_db = db.Failed(cfg.session.database.failed_downloads_path) + downloads_db = db.Downloads(cfg.session.database.downloads_path) + failed_items = failed_db.all() + + if not failed_items: + console.print("[green]No failed downloads to repair!") + return + + console.print(f"Found [yellow]{len(failed_items)}[/yellow] failed download(s).") + if not yes and not Confirm.ask("Retry them now?"): + console.print("[green]Repair aborted") + return + + # A failed item should never also be logged as downloaded, but older + # versions of streamrip could mark one downloaded even after it + # failed. Clear that stale state so the retry below isn't skipped. + for _source, _media_type, item_id in failed_items: + downloads_db.remove(id=item_id) + + async with Main(cfg) as main: + # Retry through the album rather than track by track. Resolving a + # single track builds its album metadata from the track response, + # which on Tidal carries only an id, title and cover -- no track + # count, and the track's artists in place of the album artist. The + # folder that produces differs from the album's own in both, so + # repaired tracks land in a separate folder instead of rejoining + # the album. + # + # Going through the album gets the real metadata, the right + # folder, disc subfolders and cover art, and costs nothing extra: + # tracks already in the downloads db are skipped, so only what is + # missing gets fetched. + targets, unresolved = await _albums_for(main, failed_items) + if unresolved: + console.print( + f"[yellow]{len(unresolved)} item(s) had no album to retry " + "through; fetching them individually." + ) + await main.add_all_by_id(targets + unresolved) + await main.resolve() + await main.rip() + + # Nothing in the download pipeline removes rows from the failed db, so + # success can't be detected by diffing it. But set_downloaded() is + # only reached via postprocess(), which a failed download never gets + # to, so an item in the downloads db now is one that just succeeded. + repaired = [ + item_id + for _, _, item_id in failed_items + if downloads_db.contains(id=item_id) + ] + for item_id in repaired: + failed_db.remove(id=item_id) + console.print( - f"[red]Invalid database[/red] [bold]{table}[/bold]. [red]Choose[/red] [bold]downloads " - "[red]or[/red] failed[/bold].", + f"[green]Repaired {len(repaired)}/{len(failed_items)} item(s).[/green]" ) + if len(repaired) < len(failed_items): + console.print( + f"[yellow]{len(failed_items) - len(repaired)} item(s) failed again " + "and are still logged. Run [bold]streamrip repair[/bold] to try again." + ) @rip.command() @@ -377,8 +579,8 @@ def database_browse(ctx, table): @click.option( "-n", "--num-results", - help="Maximum number of search results to show", - default=100, + help="Maximum number of search results to show " + "(default: [cli] max_search_results in the config)", type=click.IntRange(min=1), ) @click.argument("source", required=True) @@ -391,21 +593,24 @@ async def search(ctx, first, output_file, num_results, source, media_type, query Example: - rip search qobuz album 'rumours' + streamrip search qobuz album 'rumours' """ + if ctx.obj["config"] is None: + return if first and output_file: console.print("Cannot choose --first and --output-file!") return with ctx.obj["config"] as cfg: + limit = num_results or cfg.session.cli.max_search_results async with Main(cfg) as main: if first: await main.search_take_first(source, media_type, query) elif output_file: await main.search_output_file( - source, media_type, query, output_file, num_results + source, media_type, query, output_file, limit ) else: - await main.search_interactive(source, media_type, query) + await main.search_interactive(source, media_type, query, limit) await main.resolve() await main.rip() @@ -422,6 +627,8 @@ async def search(ctx, first, output_file, num_results, source, media_type, query @coro async def lastfm(ctx, source, fallback_source, url): """Download tracks from a last.fm playlist.""" + if ctx.obj["config"] is None: + return config = ctx.obj["config"] if source is not None: config.session.lastfm.source = source @@ -441,6 +648,8 @@ async def lastfm(ctx, source, fallback_source, url): @coro async def id(ctx, source, media_type, id): """Download an item by ID.""" + if ctx.obj["config"] is None: + return with ctx.obj["config"] as cfg: async with Main(cfg) as main: await main.add_by_id(source, media_type, id) @@ -448,8 +657,26 @@ async def id(ctx, source, media_type, id): await main.rip() +def _version_tuple(version: str) -> tuple[int, ...]: + """Numeric version for comparisons: "2.10.0" > "2.9.1", "v2.3" == "2.3".""" + return tuple(int(n) for n in re.findall(r"\d+", version.split("+")[0])[:3]) + + +def is_newer_version(latest: str | None, current: str = __version__) -> bool: + if not latest: + return False + try: + return _version_tuple(latest) > _version_tuple(current) + except ValueError: + return False + + async def latest_streamrip_version(verify_ssl: bool = True) -> tuple[str, str | None]: - """Get the latest streamrip version from PyPI and release notes from GitHub. + """Get the latest version of this fork and its release notes from GitHub. + + Uses the latest GitHub release, or the version in pyproject.toml on the + default branch when the repository has no releases. Network problems are + never fatal: the check is simply skipped (upstream PR #995). Args: verify_ssl: Whether to verify SSL certificates @@ -457,24 +684,31 @@ async def latest_streamrip_version(verify_ssl: bool = True) -> tuple[str, str | Returns: A tuple of (version, release_notes) """ - # Create connector with appropriate SSL settings - connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=verify_ssl) - connector = aiohttp.TCPConnector(**connector_kwargs) - - async with aiohttp.ClientSession(connector=connector) as s: - async with s.get("https://pypi.org/pypi/streamrip/json") as resp: - data = await resp.json() - version = data["info"]["version"] - - if version == __version__: - return version, None - - async with s.get( - "https://api.github.com/repos/nathom/streamrip/releases/latest" - ) as resp: - json = await resp.json() - notes = json["body"] - return version, notes + try: + timeout = aiohttp.ClientTimeout(total=10) + async with new_session(verify_ssl=verify_ssl, timeout=timeout) as s: + async with s.get( + f"https://api.github.com/repos/{REPOSITORY}/releases/latest", + headers={"Accept": "application/vnd.github+json"}, + ) as resp: + if resp.status == 200: + release = await resp.json(content_type=None) + tag = str(release.get("tag_name") or "").lstrip("vV") + if tag: + return tag, release.get("body") + + async with s.get( + f"https://raw.githubusercontent.com/{REPOSITORY}/HEAD/pyproject.toml" + ) as resp: + if resp.status == 200: + match = re.search( + r'^version\s*=\s*"([^"]+)"', await resp.text(), re.MULTILINE + ) + if match: + return match.group(1), None + except Exception as e: + logger.debug("Could not check for updates: %s", e) + return __version__, None if __name__ == "__main__": diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 562773fc..676be19b 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -2,13 +2,16 @@ import json import logging import platform +import sys import aiofiles +from rich.prompt import Confirm from .. import db from ..client import Client, DeezerClient, QobuzClient, SoundcloudClient, TidalClient from ..config import Config from ..console import console +from ..exceptions import APIError, AuthenticationError, MissingCredentialsError from ..media import ( Media, Pending, @@ -20,6 +23,7 @@ PendingSingle, remove_artwork_tempdirs, ) +from ..media.media import resolve_or_none from ..metadata import SearchResults from ..progress import clear_progress from .parse_url import parse_url @@ -27,8 +31,10 @@ logger = logging.getLogger("streamrip") -if platform.system() == "Windows": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +# Windows used to be forced onto the SelectorEventLoop because aiodns needed it, +# but that loop cannot run subprocesses, so every ffmpeg conversion failed there. +# aiodns is no longer used (connectors use aiohttp's ThreadedResolver), so the +# default ProactorEventLoop is kept. class Main: @@ -145,20 +151,45 @@ async def get_logged_in_client(self, source: str): await prompter.prompt_and_login() prompter.save() else: - with console.status(f"[cyan]Logging into {source}", spinner="dots"): - # Log into client using credentials from config - await client.login() + try: + with console.status(f"[cyan]Logging into {source}", spinner="dots"): + # Log into client using credentials from config + await client.login() + except (AuthenticationError, MissingCredentialsError) as e: + # has_creds() only checks that something is *stored*, not + # that it still works. Saved tokens expire, so the usual + # way to discover a lapsed login is a failure here -- which + # used to be a bare traceback, even though the prompter + # that fixes it is already built and sitting right there. + await self._reauthenticate(source, prompter, e) assert client.logged_in return client + async def _reauthenticate(self, source: str, prompter, cause: Exception): + """Offer a fresh login after stored credentials stop working.""" + console.print(f"[yellow]{source.title()} login failed: {cause}") + + # Never block on a prompt nobody is there to answer: streamrip is run from + # cron and from scripts, where a hidden y/n means hanging forever + # rather than failing. + if not sys.stdin.isatty(): + raise AuthenticationError( + f"{source} needs authorising again. Re-run this from an " + f"interactive terminal and you will be prompted to log in." + ) from cause + + if not Confirm.ask(f"Log into {source} again now?"): + raise cause + + await prompter.prompt_and_login() + prompter.save() + async def resolve(self): """Resolve all currently pending items.""" with console.status("Resolving URLs...", spinner="dots"): - coros = [p.resolve() for p in self.pending] - new_media: list[Media] = [ - m for m in await asyncio.gather(*coros) if m is not None - ] + resolved = await asyncio.gather(*map(resolve_or_none, self.pending)) + new_media: list[Media] = [m for m in resolved if m is not None] self.media.extend(new_media) self.pending.clear() @@ -172,7 +203,9 @@ async def rip(self): failed_items = 0 for result in results: if isinstance(result, Exception): - logger.error(f"Error processing media item: {result}") + logger.error( + f"Error processing media item: {type(result).__name__}: {result}" + ) failed_items += 1 if failed_items > 0: @@ -181,15 +214,30 @@ async def rip(self): f"Download completed with {failed_items} failed items out of {total_items} total items." ) - async def search_interactive(self, source: str, media_type: str, query: str): + async def _search( + self, source: str, media_type: str, query: str, limit: int + ) -> SearchResults | None: + """Search results, or None (with a message) if there are none.""" client = await self.get_logged_in_client(source) - with console.status(f"[bold]Searching {source}", spinner="dots"): - pages = await client.search(media_type, query, limit=100) - if len(pages) == 0: - console.print(f"[red]No search results found for query {query}") - return - search_results = SearchResults.from_pages(source, media_type, pages) + try: + pages = await client.search(media_type, query, limit=limit) + except APIError as e: + console.print(f"[red]Search failed: {e}") + return None + # A page can come back with no items in it, so count results, not pages. + search_results = SearchResults.from_pages(source, media_type, pages) + if not search_results.results: + console.print(f"[red]No search results found for query {query}") + return None + return search_results + + async def search_interactive( + self, source: str, media_type: str, query: str, limit: int = 100 + ): + search_results = await self._search(source, media_type, query, limit) + if search_results is None: + return if platform.system() == "Windows": # simple term menu not supported for windows from pick import pick @@ -234,31 +282,18 @@ async def search_interactive(self, source: str, media_type: str, query: str): ) async def search_take_first(self, source: str, media_type: str, query: str): - client = await self.get_logged_in_client(source) - with console.status(f"[bold]Searching {source}", spinner="dots"): - pages = await client.search(media_type, query, limit=1) - - if len(pages) == 0: - console.print(f"[red]No search results found for query {query}") - return - - search_results = SearchResults.from_pages(source, media_type, pages) - assert len(search_results.results) > 0 - first = search_results.results[0] - await self.add_by_id(source, first.media_type(), first.id) + search_results = await self._search(source, media_type, query, 1) + if search_results is not None: + first = search_results.results[0] + await self.add_by_id(source, first.media_type(), first.id) async def search_output_file( self, source: str, media_type: str, query: str, filepath: str, limit: int ): - client = await self.get_logged_in_client(source) - with console.status(f"[bold]Searching {source}", spinner="dots"): - pages = await client.search(media_type, query, limit=limit) - - if len(pages) == 0: - console.print(f"[red]No search results found for query {query}") + search_results = await self._search(source, media_type, query, limit) + if search_results is None: return - search_results = SearchResults.from_pages(source, media_type, pages) file_contents = json.dumps(search_results.as_list(source), indent=4) async with aiofiles.open(filepath, "w") as f: await f.write(file_contents) @@ -295,12 +330,14 @@ async def __aenter__(self): async def __aexit__(self, *_): # Ensure all client sessions are closed for client in self.clients.values(): - if hasattr(client, "session"): + if isinstance(client, TidalClient): + await client.close() # both of its logins have a session + elif hasattr(client, "session"): await client.session.close() # close global progress bar manager clear_progress() # We remove artwork tempdirs here because multiple singles - # may be able to share downloaded artwork in the same `rip` session + # may be able to share downloaded artwork in the same `streamrip` session # We don't know that a cover will not be used again until end of execution remove_artwork_tempdirs() diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index bdfa0b03..46fd2b8a 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -20,12 +20,11 @@ URL_REGEX = re.compile( r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com?(?:(?:/(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)", ) +TIDAL_SHARE_SUFFIX_REGEX = re.compile(r"^(https?://[^/]*tidal\.com/.+?)/u/?$") SOUNDCLOUD_URL_REGEX = re.compile(r"https://soundcloud.com/[-\w:/]+") -LASTFM_URL_REGEX = re.compile(r"https://www.last.fm/user/\w+/playlists/\w+") QOBUZ_INTERPRETER_URL_REGEX = re.compile( r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/([-\w]+)", ) -YOUTUBE_URL_REGEX = re.compile(r"https://www\.youtube\.com/watch\?v=[-\w]+") class URL(ABC): @@ -54,6 +53,12 @@ async def into_pending( class GenericURL(URL): @classmethod def from_str(cls, url: str) -> URL | None: + # Tidal's share sheet produces links ending in "/u". URL_REGEX takes + # the last path segment as the item id -- it has to, because Qobuz + # album urls look like //album// -- so that suffix + # would be parsed as an id of "u" and the API would 404. + url = TIDAL_SHARE_SUFFIX_REGEX.sub(r"\1", url) + generic_url = URL_REGEX.match(url) if generic_url is None: return None @@ -136,9 +141,13 @@ async def extract_interpreter_url(url: str, client: Client) -> str: class DeezerDynamicURL(URL): standard_link_re = re.compile( - r"https://www\.deezer\.com/[a-z]{2}/(album|artist|playlist|track)/(\d+)" + r"https://www\.deezer\.com/(?:[a-z]{2}(?:-[a-z]{2})?/)?(album|artist|playlist|track)/(\d+)" + ) + # Share links: the old Firebase ones (deezer.page.link / dzr.page.link) + # and the current link.deezer.com/s/... ones (upstream #865, #818). + dynamic_link_re = re.compile( + r"https://(?:(?:deezer|dzr)\.page\.link|link\.deezer\.com/s)/[\w-]+" ) - dynamic_link_re = re.compile(r"https://(?:deezer|dzr)\.page\.link/\w+") @classmethod def from_str(cls, url: str) -> URL | None: @@ -179,12 +188,44 @@ async def _extract_info_from_dynamic_link( :rtype: Tuple[str, str] (media type, item id) """ async with client.session.get(url) as resp: - match = cls.standard_link_re.search(await resp.text()) + # Share links redirect to the regular www.deezer.com URL, which is + # the most reliable place to read the id from; fall back to the + # page body for the older Firebase links. + match = cls.standard_link_re.search(str(resp.url)) + if match is None: + match = cls.standard_link_re.search(await resp.text()) if match: return match.group(1), match.group(2) - raise Exception("Unable to extract Deezer dynamic link.") + raise Exception(f"Unable to extract the Deezer item from {url}.") + + +class DeezerFavoriteURL(URL): + """Matches Deezer liked-tracks profile URLs. + + Example: https://www.deezer.com/fr/profile/1234567/loved + """ + + favorite_re = re.compile( + r"https://(?:www\.)?deezer\.com/[a-z]{2}/profile/(\d+)/loved" + ) + + @classmethod + def from_str(cls, url: str) -> URL | None: + match = cls.favorite_re.match(url) + if match is None: + return None + return cls(match, "deezer") + + async def into_pending( + self, + client: Client, + config: Config, + db: Database, + ) -> Pending: + user_id = self.match.group(1) + return PendingPlaylist(f"favorites:{user_id}", client, config, db) class SoundcloudURL(URL): @@ -232,6 +273,6 @@ def parse_url(url: str) -> URL | None: QobuzInterpreterURL.from_str(url), SoundcloudURL.from_str(url), DeezerDynamicURL.from_str(url), - # TODO: the rest of the url types + DeezerFavoriteURL.from_str(url), ] return next((u for u in parsed_urls if u is not None), None) diff --git a/streamrip/rip/prompter.py b/streamrip/rip/prompter.py index 0cc30b83..b5006aba 100644 --- a/streamrip/rip/prompter.py +++ b/streamrip/rip/prompter.py @@ -5,12 +5,17 @@ from abc import ABC, abstractmethod from click import launch -from rich.prompt import Prompt +from rich.prompt import Confirm, Prompt from ..client import Client, DeezerClient, QobuzClient, SoundcloudClient, TidalClient from ..config import Config from ..console import console from ..exceptions import AuthenticationError, MissingCredentialsError +from .qobuz_token_capture import ( + QobuzTokenCaptureError, + capture_qobuz_auth_token, + playwright_available, +) logger = logging.getLogger("streamrip") @@ -52,35 +57,83 @@ def has_creds(self) -> bool: async def prompt_and_login(self): if not self.has_creds(): - self._prompt_creds_and_set_session_config() + await self._prompt_creds_and_set_session_config() while True: try: await self.client.login() break - except AuthenticationError: - console.print("[yellow]Invalid credentials, try again.") - self._prompt_creds_and_set_session_config() + except AuthenticationError as e: + console.print(f"[yellow]{e}") + await self._prompt_creds_and_set_session_config() except MissingCredentialsError: - self._prompt_creds_and_set_session_config() + await self._prompt_creds_and_set_session_config() + + async def _prompt_creds_and_set_session_config(self): + """Ask for a user id + user_auth_token (or, as a fallback, a password). + + Qobuz moved its web login behind OAuth/reCAPTCHA, so the old + email/password flow fails for most accounts (upstream #954, #956). + The token from a logged-in browser session still works. + """ + if await self._try_browser_capture(): + return + + console.print( + "\n[cyan]Qobuz now requires a token login.[/cyan]\n" + " 1. Log in at [blue underline]https://play.qobuz.com/login[/]\n" + " 2. Open your browser's DevTools -> Network tab\n" + " 3. Find the [bold]user/login[/bold] request and open its response\n" + " 4. Copy [bold]user.id[/bold] and [bold]user_auth_token[/bold]\n" + "Leave the user id empty to log in with email and password instead.\n" + ) + user_id = Prompt.ask("Enter your Qobuz user id", default="").strip() + if user_id: + token = Prompt.ask( + "Enter your Qobuz user_auth_token (invisible)", password=True + ).strip() + self._set_session_creds(True, user_id, token) + return - def _prompt_creds_and_set_session_config(self): email = Prompt.ask("Enter your Qobuz email") pwd_input = Prompt.ask("Enter your Qobuz password (invisible)", password=True) - pwd = hashlib.md5(pwd_input.encode("utf-8")).hexdigest() + self._set_session_creds(False, email, pwd) + + async def _try_browser_capture(self) -> bool: + """Grab the token from a real browser login, if Playwright is usable. + + The Chromium build it needs downloads itself automatically the + first time this actually runs. + """ + if not playwright_available(): + return False + if not Confirm.ask( + "Open a browser window to log into Qobuz and capture the token?", + default=True, + ): + return False + try: + user_id, token = await capture_qobuz_auth_token(timeout_s=300) + except QobuzTokenCaptureError as e: + console.print(f"[yellow]{e}") + return False + self._set_session_creds(True, user_id, token) + return True + + def _set_session_creds(self, use_auth_token: bool, user: str, secret: str): + c = self.config.session.qobuz + c.use_auth_token = use_auth_token + c.email_or_userid = user + c.password_or_token = secret console.print( - f"[green]Credentials saved to config file at [bold cyan]{self.config.path}", + f"[green]Credentials will be saved to [bold cyan]{self.config.path}", ) - c = self.config.session.qobuz - c.use_auth_token = False - c.email_or_userid = email - c.password_or_token = pwd def save(self): c = self.config.session.qobuz cf = self.config.file.qobuz - cf.use_auth_token = False + cf.use_auth_token = c.use_auth_token cf.email_or_userid = c.email_or_userid cf.password_or_token = c.password_or_token self.config.file.set_modified() @@ -95,43 +148,69 @@ class TidalPrompter(CredentialPrompter): client: TidalClient def has_creds(self) -> bool: - return len(self.config.session.tidal.access_token) > 0 + return all(lane.tokens.access_token for lane in self.client.lanes()) async def prompt_and_login(self): - device_code, uri = await self.client._get_device_code() - login_link = f"https://{uri}" + """Log in every lane that has no working login yet.""" + for lane in self.client.lanes(): + if lane.tokens.access_token: + try: + await lane._login_lane() + continue + except AuthenticationError, MissingCredentialsError: + pass + await self._device_login(lane) + self.client.logged_in = True + self.save() + + async def _device_login(self, lane: TidalClient): + if len(self.client.lanes()) > 1: + console.print( + "Tidal serves hi-res and CD quality through two separate logins. " + f"This one is for {'CD quality' if lane is self.client else 'hi-res'}." + ) + device_code, uri = await lane._get_device_code() + login_link = uri if uri.startswith("http") else f"https://{uri}" console.print( - f"Go to [blue underline]{login_link}[/blue underline] to log into Tidal within 5 minutes.", + f"Go to [blue underline]{login_link}[/blue underline] to log into Tidal " + f"within {self.timeout_s // 60} minutes.", ) - launch(login_link) + try: + launch(login_link) + except Exception: + # No browser available (e.g. over SSH); the link is printed above. + pass start = time.time() - elapsed = 0.0 - info = {} - while elapsed < self.timeout_s: - elapsed = time.time() - start - status, info = await self.client._get_auth_status(device_code) + info: dict = {} + while True: + if time.time() - start > self.timeout_s: + raise AuthenticationError("Timed out waiting for the Tidal login.") + status, info = await lane._get_auth_status(device_code) if status == 2: # pending await asyncio.sleep(4) continue - elif status == 0: + if status == 0: # successful break - else: - raise Exception + raise AuthenticationError( + "Tidal rejected the device login. Try again, or check the " + "[tidal] client settings in the config." + ) c = self.config.session.tidal c.user_id = info["user_id"] # type: ignore c.country_code = info["country_code"] # type: ignore - c.access_token = info["access_token"] # type: ignore - c.refresh_token = info["refresh_token"] # type: ignore - c.token_expiry = info["token_expiry"] # type: ignore + t = lane.tokens + t.access_token = info["access_token"] # type: ignore + t.refresh_token = info["refresh_token"] # type: ignore + t.token_expiry = info["token_expiry"] # type: ignore + t.token_client_id = lane.client_id - self.client._update_authorization_from_config() - self.client.logged_in = True - self.save() + lane._update_authorization_from_config() + lane.logged_in = True def type_check_client(self, client) -> TidalClient: assert isinstance(client, TidalClient) @@ -142,9 +221,8 @@ def save(self): cf = self.config.file.tidal cf.user_id = c.user_id cf.country_code = c.country_code - cf.access_token = c.access_token - cf.refresh_token = c.refresh_token - cf.token_expiry = c.token_expiry + for lane in self.client.lanes(): + lane.save_login() self.config.file.set_modified() diff --git a/streamrip/rip/qobuz_token_capture.py b/streamrip/rip/qobuz_token_capture.py new file mode 100644 index 00000000..0034dc67 --- /dev/null +++ b/streamrip/rip/qobuz_token_capture.py @@ -0,0 +1,164 @@ +"""Capture a Qobuz user id and user_auth_token from a real browser login. + +Qobuz's web login is behind OAuth/reCAPTCHA, so streamrip cannot log in with a +password any more for most accounts. Instead, open a browser with Playwright, +let the user log in normally, and read the token from the ``user/login`` +response. Playwright itself is a normal dependency; the Chromium build it +drives is fetched automatically, once, the first time it's actually needed +(``playwright install`` downloads a real browser, which can't be bundled in +the Python package). +""" + +import asyncio +import importlib.util +import logging +import platform +import sys +import time + +from ..console import console + +logger = logging.getLogger("streamrip") + +QOBUZ_LOGIN_PAGE = "https://play.qobuz.com/login" +QOBUZ_LOGIN_API = "https://www.qobuz.com/api.json/0.2/user/login" + + +class QobuzTokenCaptureError(Exception): + """Raised when automatic Qobuz token capture fails.""" + + +def playwright_available() -> bool: + """Whether the optional Playwright dependency is installed.""" + return importlib.util.find_spec("playwright") is not None + + +async def _install_chromium() -> None: + """Download the Chromium build Playwright drives, once. + + The Python package alone doesn't include a browser binary; Playwright + fetches one on first use instead of streamrip shipping it, and this + keeps that one-time fetch automatic instead of a documented manual step. + """ + console.print( + "[cyan]First-time setup: downloading a browser for automatic Qobuz " + "login (about 150 MB, once only)..." + ) + proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "playwright", + "install", + "chromium", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + out, _ = await proc.communicate() + if proc.returncode != 0: + raise QobuzTokenCaptureError( + "Could not download the browser automatically " + f"(exit {proc.returncode}): {out.decode(errors='replace')[-500:]}" + ) + + +async def _capture_qobuz_auth_token_async(timeout_s: int = 300) -> tuple[str, str]: + """Capture user id and auth token from Qobuz web login traffic. + + Returns: + Tuple of (user_id, user_auth_token) + """ + try: + from playwright.async_api import async_playwright + except Exception as exc: # pragma: no cover - import path only + raise QobuzTokenCaptureError( + "Automatic browser capture requires Playwright, which failed to " + "import. Reinstall streamrip, or enter the token manually." + ) from exc + + result: dict[str, str] = {} + + async def handle_response(response): + if response.url != QOBUZ_LOGIN_API: + return + + try: + post_data = response.request.post_data or "" + if post_data and "extra=partner" not in post_data: + return + if response.status != 200: + return + payload = await response.json() + except Exception: + return + + if not isinstance(payload, dict): + return + + user = payload.get("user", {}) + user_id = user.get("id") + token = payload.get("user_auth_token") + if user_id is None or not token: + return + + result["user_id"] = str(user_id) + result["token"] = str(token) + + try: + async with async_playwright() as playwright: + try: + browser = await playwright.chromium.launch(headless=False) + except Exception as launch_exc: + if "playwright install" not in str(launch_exc): + raise + await _install_chromium() + browser = await playwright.chromium.launch(headless=False) + context = await browser.new_context() + page = await context.new_page() + page.on("response", handle_response) + await page.goto(QOBUZ_LOGIN_PAGE, wait_until="domcontentloaded") + + logger.info( + "Waiting for Qobuz login response in browser (timeout: %ss).", + timeout_s, + ) + deadline = time.monotonic() + timeout_s + while "token" not in result and time.monotonic() < deadline: + await page.wait_for_timeout(250) + + await context.close() + await browser.close() + except Exception as exc: + raise QobuzTokenCaptureError( + f"Automatic browser capture failed: {exc}" + ) from exc + + if "token" not in result or "user_id" not in result: + raise QobuzTokenCaptureError( + "Could not detect a successful Qobuz user/login response. " + "Please complete login in the opened browser or use manual token input." + ) + + return result["user_id"], result["token"] + + +def _capture_qobuz_auth_token_windows(timeout_s: int) -> tuple[str, str]: + """Run Playwright capture in an isolated Proactor loop on Windows.""" + if not hasattr(asyncio, "ProactorEventLoop"): + raise QobuzTokenCaptureError( + "Windows Proactor event loop is unavailable; use manual token input." + ) + + loop = asyncio.ProactorEventLoop() # type: ignore[attr-defined] + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete(_capture_qobuz_auth_token_async(timeout_s)) + finally: + loop.close() + asyncio.set_event_loop(None) + + +async def capture_qobuz_auth_token(timeout_s: int = 300) -> tuple[str, str]: + """Capture user id and auth token from Qobuz web login traffic.""" + if platform.system() == "Windows": + return await asyncio.to_thread(_capture_qobuz_auth_token_windows, timeout_s) + return await _capture_qobuz_auth_token_async(timeout_s) diff --git a/streamrip/rip/user_paths.py b/streamrip/rip/user_paths.py deleted file mode 100644 index 4cb5ea3e..00000000 --- a/streamrip/rip/user_paths.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -from pathlib import Path - -from appdirs import user_config_dir - -APPNAME = "streamrip" -APP_DIR = user_config_dir(APPNAME) -HOME = Path.home() - -LOG_DIR = CACHE_DIR = CONFIG_DIR = APP_DIR -DEFAULT_CONFIG_PATH = os.path.join(CONFIG_DIR, "config.toml") - -DOWNLOADS_DIR = os.path.join(HOME, "StreamripDownloads") -# file shipped with script -BLANK_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.toml") - -DEFAULT_DOWNLOADS_FOLDER = os.path.join(HOME, "StreamripDownloads") -DEFAULT_DOWNLOADS_DB_PATH = os.path.join(LOG_DIR, "downloads.db") -DEFAULT_FAILED_DOWNLOADS_DB_PATH = os.path.join(LOG_DIR, "failed_downloads.db") -DEFAULT_YOUTUBE_VIDEO_DOWNLOADS_FOLDER = os.path.join(DOWNLOADS_DIR, "YouTubeVideos") diff --git a/streamrip/utils/ffmpeg_utils.py b/streamrip/utils/ffmpeg_utils.py new file mode 100644 index 00000000..18761241 --- /dev/null +++ b/streamrip/utils/ffmpeg_utils.py @@ -0,0 +1,23 @@ +"""Locate an FFmpeg executable.""" + +import functools +import shutil + + +@functools.lru_cache(maxsize=1) +def find_ffmpeg() -> str | None: + """Return a path to an ffmpeg executable, or None if none is available. + + Prefers an ffmpeg already on PATH. Falls back to the binary bundled by + the optional `ffmpeg` extra (imageio-ffmpeg), so streamrip works without + a separate, OS-specific ffmpeg install. + """ + path = shutil.which("ffmpeg") + if path is not None: + return path + try: + import imageio_ffmpeg + + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + return None diff --git a/streamrip/utils/ssl_utils.py b/streamrip/utils/ssl_utils.py index e1a912a8..681ee9dc 100644 --- a/streamrip/utils/ssl_utils.py +++ b/streamrip/utils/ssl_utils.py @@ -1,4 +1,4 @@ -"""Utility functions for SSL handling.""" +"""Certificate checking for HTTPS connections.""" import logging import ssl @@ -8,54 +8,20 @@ try: import certifi - - HAS_CERTIFI = True except ImportError: logger.debug("certifi not found, falling back to system certificates") - HAS_CERTIFI = False - - -def create_ssl_context(verify=True): - """Create an SSL context with the appropriate verification settings. - - Args: - verify: Whether to verify SSL certificates - - Returns: - An SSL context object with the specified verification settings - """ - if not verify: - # Disable verification entirely when requested - logger.warning("SSL certificate verification disabled (less secure)") - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - return ctx - - # Use certifi for certificate verification if available - if HAS_CERTIFI: - return ssl.create_default_context(cafile=certifi.where()) - else: - return ssl.create_default_context() - + certifi = None -def get_aiohttp_connector_kwargs(verify_ssl=True): - """Get keyword arguments for aiohttp.TCPConnector with SSL settings. - Args: - verify_ssl: Whether to verify SSL certificates - - Returns: - Dictionary of kwargs to pass to aiohttp.TCPConnector +def get_aiohttp_connector_kwargs(verify_ssl: bool = True) -> dict: + """Arguments for aiohttp.TCPConnector: check certificates against + certifi's bundle when it's installed, else the system's, or not at all. """ if not verify_ssl: - return {"verify_ssl": False} - - if HAS_CERTIFI: - ssl_context = create_ssl_context(verify=True) - return {"ssl": ssl_context} - else: - return {"verify_ssl": True} + return {"ssl": False} + if certifi is not None: + return {"ssl": ssl.create_default_context(cafile=certifi.where())} + return {"ssl": True} def print_ssl_error_help(): @@ -64,7 +30,7 @@ def print_ssl_error_help(): print("Options:") print(" 1. Run again with the --no-ssl-verify flag (less secure)") print( - ' Example: rip --no-ssl-verify url "https://tidal.com/browse/playlist/..."' + ' Example: streamrip --no-ssl-verify url "https://tidal.com/browse/playlist/..."' ) print() print(" 2. Install certifi for better certificate handling:") diff --git a/tests/qobuz_album_resp.json b/tests/qobuz_album_resp.json index be4d1a55..6f92f2aa 100644 --- a/tests/qobuz_album_resp.json +++ b/tests/qobuz_album_resp.json @@ -16,7 +16,13 @@ "picture": null }, "artists": [ - { "id": 132127, "name": "Fleetwood Mac", "roles": ["main-artist"] } + { + "id": 132127, + "name": "Fleetwood Mac", + "roles": [ + "main-artist" + ] + } ], "upc": "0603497941032", "released_at": 223858800, @@ -36,11 +42,14 @@ "popularity": 0, "tracks_count": 11, "genre": { - "path": [112, 119], + "path": [ + 112, + 119 + ], "color": "#5eabc1", - "name": "Rock", + "name": "Pop/Rock", "id": 119, - "slug": "rock" + "slug": "pop-rock" }, "maximum_channel_count": 2, "id": "0603497941032", @@ -83,7 +92,10 @@ "image": null }, "created_at": 0, - "genres_list": ["Pop/Rock", "Pop/Rock\u2192Rock"], + "genres_list": [ + "Pop/Rock", + "Pop/Rock\u2192Rock" + ], "period": null, "copyright": "\u00a9 1977 Warner Records Inc. \u2117 1977 Warner Records Inc. Marketed by Rhino Entertainment Company, A Warner Music Group Company.", "is_official": true, @@ -95,9 +107,11 @@ "product_url": "/fr-fr/album/rumours-fleetwood-mac/0603497941032", "recording_information": "", "relative_url": "/album/rumours-fleetwood-mac/0603497941032", - "release_tags": ["remaster"], + "release_tags": [ + "remaster" + ], "release_type": "album", "slug": "rumours-fleetwood-mac", "subtitle": "Fleetwood Mac", "description": "" -} +} \ No newline at end of file diff --git a/tests/silence.m4a b/tests/silence.m4a new file mode 100644 index 00000000..43b83709 Binary files /dev/null and b/tests/silence.m4a differ diff --git a/tests/silence_alac.m4a b/tests/silence_alac.m4a new file mode 100644 index 00000000..9f00902c Binary files /dev/null and b/tests/silence_alac.m4a differ diff --git a/tests/test_album.py b/tests/test_album.py new file mode 100644 index 00000000..a274cc2b --- /dev/null +++ b/tests/test_album.py @@ -0,0 +1,209 @@ +"""Album downloads: bounded resolving, and one log line for what is already done.""" + +import asyncio +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from streamrip.media import album as album_module +from streamrip.media.album import RESOLVE_CONCURRENCY, Album, PendingAlbum + + +def _meta(album="Album"): + meta = MagicMock() + meta.album = album + meta.info.container = "FLAC" + meta.info.bit_depth = None + meta.info.sampling_rate = None + return meta + + +def _album(tracks): + config = MagicMock() + config.session.metadata.prefer_explicit = False + return Album( + meta=_meta(), tracks=tracks, config=config, folder="/x", db=MagicMock() + ) + + +@pytest.mark.asyncio +async def test_resolves_only_a_few_tracks_at_once(): + concurrent = peak = 0 + + class Pending: + async def resolve(self): + nonlocal concurrent, peak + concurrent += 1 + peak = max(peak, concurrent) + await asyncio.sleep(0) + concurrent -= 1 + track = MagicMock() + track.rip = AsyncMock() + return track + + await _album([Pending() for _ in range(RESOLVE_CONCURRENCY * 3)]).download() + assert 1 < peak <= RESOLVE_CONCURRENCY + + +@pytest.mark.asyncio +async def test_a_downloading_track_does_not_hold_a_resolve_slot(): + n = RESOLVE_CONCURRENCY * 3 + resolved = 0 + all_resolved = asyncio.Event() + + class Track: + async def rip(self): + # Only finishes once every track has been resolved, which can't + # happen if downloads sit on the few resolve slots. + await all_resolved.wait() + + class Pending: + async def resolve(self): + nonlocal resolved + await asyncio.sleep(0) + resolved += 1 + if resolved == n: + all_resolved.set() + return Track() + + await asyncio.wait_for(_album([Pending() for _ in range(n)]).download(), 5) + assert resolved == n + + +def _pending_album(monkeypatch, tmp_path, tracklist, downloaded): + meta = _meta("Encore") + client = MagicMock() + client.source = "tidal" + client.get_metadata = AsyncMock(return_value={}) + db = MagicMock() + db.downloaded.side_effect = lambda track_id: track_id in downloaded + config = MagicMock() + config.session.downloads.folder = str(tmp_path) + artwork = AsyncMock(return_value=("cover.jpg", None)) + monkeypatch.setattr( + album_module.AlbumMetadata, "from_album_resp", lambda resp, source: meta + ) + monkeypatch.setattr( + album_module, "get_album_track_ids", lambda source, resp: tracklist + ) + monkeypatch.setattr(album_module, "download_artwork", artwork) + monkeypatch.setattr( + album_module, "album_folder", lambda config, source, m: str(tmp_path / "Encore") + ) + return PendingAlbum("1", client, config, db), artwork + + +@pytest.mark.asyncio +async def test_finished_album_is_skipped_without_cover_or_folder( + monkeypatch, tmp_path, caplog +): + pending, artwork = _pending_album( + monkeypatch, tmp_path, ["1", "2", "3"], downloaded={"1", "2", "3"} + ) + with caplog.at_level(logging.INFO, logger="streamrip"): + album = await pending.resolve() + assert album.tracks == [] + artwork.assert_not_awaited() + assert not (tmp_path / "Encore").exists() + assert "Encore: all 3 tracks already downloaded" in caplog.text + + +@pytest.mark.asyncio +async def test_partly_downloaded_album_reports_once_and_keeps_the_rest( + monkeypatch, tmp_path, caplog +): + pending, artwork = _pending_album( + monkeypatch, tmp_path, ["1", "2", "3"], downloaded={"1"} + ) + with caplog.at_level(logging.INFO, logger="streamrip"): + album = await pending.resolve() + assert [t.id for t in album.tracks] == ["2", "3"] + artwork.assert_awaited_once() + assert (tmp_path / "Encore").is_dir() + assert "Encore: skipping 1 of 3 tracks already downloaded" in caplog.text + assert "Skipping track" not in caplog.text + + +@pytest.mark.asyncio +async def test_new_album_logs_nothing_about_skipping(monkeypatch, tmp_path, caplog): + pending, _ = _pending_album(monkeypatch, tmp_path, ["1", "2"], downloaded=set()) + with caplog.at_level(logging.INFO, logger="streamrip"): + album = await pending.resolve() + assert [t.id for t in album.tracks] == ["1", "2"] + assert "skipping" not in caplog.text.lower() + + +class _FakeDownloadable: + downloaded: list[str] = [] + + def __init__(self, _session, url, _extension): + self.url = url + + async def download(self, path, _callback): + if "broken" in self.url: + raise ConnectionError("gone") + type(self).downloaded.append(self.url) + with open(path, "wb") as f: + f.write(b"%PDF") + + +@pytest.mark.asyncio +async def test_booklets_are_saved_as_pdfs(monkeypatch, tmp_path, caplog): + monkeypatch.setattr(album_module, "BasicDownloadable", _FakeDownloadable) + _FakeDownloadable.downloaded = [] + (tmp_path / "Liner Notes.pdf").write_bytes(b"%PDF") + booklets = [ + {"description": "Digital Booklet", "url": "https://q/1.pdf"}, + {"name": "Poster", "url": "https://q/2.jpg"}, # not a booklet + {"description": "Liner Notes", "url": "https://q/3.pdf"}, # already there + {"description": "Lyrics", "url": "https://q/broken.pdf"}, + {"description": "Digital Booklet", "url": "https://q/5.pdf"}, + ] + + await album_module.download_booklets(None, booklets, str(tmp_path)) + + assert _FakeDownloadable.downloaded == ["https://q/1.pdf", "https://q/5.pdf"] + # Only names that would collide get a number. + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "Digital Booklet 1.pdf", + "Digital Booklet 4.pdf", + "Liner Notes.pdf", + ] + assert "Could not download booklet https://q/broken.pdf" in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [True, False]) +async def test_booklets_follow_the_config(monkeypatch, tmp_path, enabled): + pending, _ = _pending_album(monkeypatch, tmp_path, ["1"], downloaded=set()) + booklets = [{"description": "Booklet", "url": "https://q/1.pdf"}] + album_module.AlbumMetadata.from_album_resp(None, None).info.booklets = booklets + pending.config.session.qobuz.download_booklets = enabled + download = AsyncMock() + monkeypatch.setattr(album_module, "download_booklets", download) + monkeypatch.setattr(album_module, "progress", MagicMock()) + + album = await pending.resolve() + # Resolving alone downloads nothing: an album an artist or label filter + # drops afterwards must not leave its booklets behind. + assert download.await_count == 0 + + await album.preprocess() + assert download.await_count == (1 if enabled else 0) + + +@pytest.mark.asyncio +async def test_finished_album_gets_no_booklets(monkeypatch, tmp_path): + pending, _ = _pending_album(monkeypatch, tmp_path, ["1"], downloaded={"1"}) + album_module.AlbumMetadata.from_album_resp(None, None).info.booklets = [ + {"description": "Booklet", "url": "https://q/1.pdf"} + ] + pending.config.session.qobuz.download_booklets = True + download = AsyncMock() + monkeypatch.setattr(album_module, "download_booklets", download) + monkeypatch.setattr(album_module, "progress", MagicMock()) + + await (await pending.resolve()).preprocess() + + download.assert_not_awaited() diff --git a/tests/test_artist.py b/tests/test_artist.py new file mode 100644 index 00000000..60c26d1b --- /dev/null +++ b/tests/test_artist.py @@ -0,0 +1,176 @@ +import asyncio + +import pytest + +from streamrip.config import ArtistFilterConfig +from streamrip.media.artist import RESOLVE_CHUNK_SIZE, Artist +from streamrip.media.label import Label + + +class _FakeAlbum: + def __init__(self): + self.ripped = False + + async def rip(self): + self.ripped = True + + +class _FakePendingAlbum: + """Stands in for PendingAlbum: tracks how many resolve() calls are in + flight at once, without needing a real Client/Config/API response. + """ + + _concurrent = 0 + _max_concurrent = 0 + + async def resolve(self): + type(self)._concurrent += 1 + type(self)._max_concurrent = max( + type(self)._max_concurrent, type(self)._concurrent + ) + await asyncio.sleep(0) # yield, so overlapping calls can interleave + type(self)._concurrent -= 1 + return _FakeAlbum() + + +NO_FILTERS = ArtistFilterConfig( + extras=False, + repeats=False, + non_albums=False, + features=False, + non_remaster=False, +) + + +@pytest.mark.asyncio +async def test_albums_rip_strictly_one_at_a_time(): + """RESOLVE_CHUNK_SIZE == 1: no album's tracks start until the last + album's are done downloading, so the progress display never shows two + albums' tracks at once. + """ + concurrent = 0 + max_concurrent = 0 + + class Album: + async def rip(self): + nonlocal concurrent, max_concurrent + concurrent += 1 + max_concurrent = max(max_concurrent, concurrent) + await asyncio.sleep(0) + concurrent -= 1 + + class Pending: + async def resolve(self): + return Album() + + albums = [Pending() for _ in range(RESOLVE_CHUNK_SIZE * 3 + 1)] + artist = Artist(name="Test Artist", albums=albums, client=None, config=None) + + await asyncio.wait_for(artist._download_async(NO_FILTERS), 5) + + assert max_concurrent == 1 + + +@pytest.mark.asyncio +async def test_a_failing_album_does_not_stop_the_others(caplog): + ripped = [] + + class Album: + def __init__(self, n): + self.n = n + + async def rip(self): + ripped.append(self.n) + + class Pending: + def __init__(self, n, fail=False): + self.n, self.fail = n, fail + + async def resolve(self): + if self.fail: + raise ConnectionError("boom") + return Album(self.n) + + albums = [Pending(1), Pending(2, fail=True), Pending(3)] + artist = Artist(name="Test Artist", albums=albums, client=None, config=None) + + await artist._download_async(NO_FILTERS) + + assert sorted(ripped) == [1, 3] + assert "Error downloading album: ConnectionError: boom" in caplog.text + + +@pytest.mark.asyncio +async def test_resolve_then_download_chunks_the_resolve_phase(): + """Used when artist_filters.repeats is on: resolving every album's title + upfront is required, but that shouldn't mean firing them all at once. + """ + _FakePendingAlbum._concurrent = 0 + _FakePendingAlbum._max_concurrent = 0 + albums = [_FakePendingAlbum() for _ in range(RESOLVE_CHUNK_SIZE * 3 + 1)] + artist = Artist(name="Test Artist", albums=albums, client=None, config=None) + + await artist._resolve_then_download(NO_FILTERS) + + assert _FakePendingAlbum._max_concurrent <= RESOLVE_CHUNK_SIZE + + +@pytest.mark.asyncio +async def test_resolve_then_download_survives_a_failing_resolve(caplog): + ripped = [] + + class Album: + def __init__(self, n): + self.n = n + + async def rip(self): + ripped.append(self.n) + + class Pending: + def __init__(self, n, fail=False): + self.n, self.fail = n, fail + + async def resolve(self): + if self.fail: + raise ConnectionError("boom") + return Album(self.n) + + albums = [Pending(1), Pending(2, fail=True), Pending(3)] + artist = Artist(name="Test Artist", albums=albums, client=None, config=None) + + await artist._resolve_then_download(NO_FILTERS) + + assert sorted(ripped) == [1, 3] + assert "Error resolving album: ConnectionError: boom" in caplog.text + + +@pytest.mark.asyncio +async def test_a_failing_label_album_does_not_stop_the_others(): + ripped = [] + + class Album: + def __init__(self, n): + self.n = n + + async def rip(self): + if self.n == 2: + raise ConnectionError("boom") + ripped.append(self.n) + + class Pending: + def __init__(self, n): + self.n = n + + async def resolve(self): + return Album(self.n) + + label = Label( + name="Test Label", + albums=[Pending(n) for n in (1, 2, 3)], + client=None, + config=None, + ) + + await label.download() + + assert sorted(ripped) == [1, 3] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..35bfb5b7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,168 @@ +import tomlkit +from click.testing import CliRunner + +from streamrip import db +from streamrip.config import set_user_defaults +from streamrip.rip.cli import UPGRADE_COMMAND, is_newer_version, rip + + +def test_version_comparison_is_numeric(): + assert is_newer_version("2.10.0", "2.9.9") + assert is_newer_version("v2.4", "2.3.0") + assert not is_newer_version("2.3.0", "2.3.0") + assert not is_newer_version("2.2.0", "2.3.0") + assert not is_newer_version(None, "2.3.0") + + +def test_upgrade_command_installs_from_the_fork(): + assert "github.com/Stensel8/streamrip" in UPGRADE_COMMAND + assert "pip install streamrip" not in UPGRADE_COMMAND + + +def test_help_lists_commands(tmp_path): + result = CliRunner().invoke( + rip, ["--config-path", str(tmp_path / "config.toml"), "--help"] + ) + assert result.exit_code == 0 + for command in ("url", "file", "search", "lastfm", "id", "repair", "config"): + assert command in result.output + + +def test_codec_choice_accepts_opus_and_aiff(tmp_path): + cfg = str(tmp_path / "config.toml") + for codec in ("opus", "AIFF"): + result = CliRunner().invoke( + rip, ["--config-path", cfg, "-c", codec, "config", "path"] + ) + assert result.exit_code == 0, result.output + result = CliRunner().invoke( + rip, ["--config-path", cfg, "-c", "wma", "config", "path"] + ) + assert result.exit_code != 0 + + +def _seeded_databases(tmp_path): + """A config whose databases live in tmp_path (never the real config dir), + plus two downloaded tracks and one failed download. + """ + path = tmp_path / "config.toml" + set_user_defaults(str(path)) + doc = tomlkit.parse(path.read_text()) + doc["database"]["downloads_path"] = str(tmp_path / "downloads.db") + doc["database"]["failed_downloads_path"] = str(tmp_path / "failed.db") + path.write_text(tomlkit.dumps(doc)) + + downloads = db.Downloads(str(tmp_path / "downloads.db")) + failed = db.Failed(str(tmp_path / "failed.db")) + for track_id in ("1", "2"): + downloads.add((track_id,)) + failed.add(("tidal", "track", "3")) + return str(path), downloads, failed + + +def _clear(cfg, *args, **kwargs): + return CliRunner().invoke( + rip, ["--config-path", cfg, "database", "clear", *args], **kwargs + ) + + +def test_database_clear_downloads_leaves_failed_alone(tmp_path): + cfg, downloads, failed = _seeded_databases(tmp_path) + result = _clear(cfg, "downloads", "-y") + assert result.exit_code == 0, result.output + assert downloads.all() == [] + assert len(failed.all()) == 1 + assert "Cleared 2 downloaded track(s)" in result.output + + +def test_database_clear_all(tmp_path): + cfg, downloads, failed = _seeded_databases(tmp_path) + result = _clear(cfg, "all", "-y") + assert result.exit_code == 0, result.output + assert downloads.all() == [] + assert failed.all() == [] + + +def test_database_clear_table_name_is_case_insensitive(tmp_path): + cfg, downloads, _ = _seeded_databases(tmp_path) + assert _clear(cfg, "Downloads", "-y").exit_code == 0 + assert downloads.all() == [] + + +def test_database_clear_asks_first_and_can_be_declined(tmp_path): + cfg, downloads, _ = _seeded_databases(tmp_path) + result = _clear(cfg, "downloads", input="n\n") + assert result.exit_code == 0, result.output + assert len(downloads.all()) == 2 + assert "Clear aborted" in result.output + + +def test_database_clear_when_confirmed(tmp_path): + cfg, downloads, _ = _seeded_databases(tmp_path) + result = _clear(cfg, "downloads", input="y\n") + assert result.exit_code == 0, result.output + assert downloads.all() == [] + + +def test_database_clear_with_nothing_to_clear(tmp_path): + cfg, downloads, _ = _seeded_databases(tmp_path) + downloads.clear() + result = _clear(cfg, "downloads") + assert result.exit_code == 0, result.output + assert "Nothing to clear" in result.output + + +def test_database_clear_rejects_an_unknown_table(tmp_path): + cfg, downloads, _ = _seeded_databases(tmp_path) + assert _clear(cfg, "everything", "-y").exit_code != 0 + assert len(downloads.all()) == 2 + + +def test_database_browse_failed_lines_up_with_its_headers(tmp_path): + cfg, _, _ = _seeded_databases(tmp_path) + result = CliRunner().invoke( + rip, ["--config-path", cfg, "database", "browse", "failed"] + ) + assert result.exit_code == 0, result.output + lines = result.output.splitlines() + header = next(line for line in lines if "Source" in line) + row = next(line for line in lines if "tidal" in line) + cells = dict( + zip( + [c.strip() for c in header.split("┃")[1:-1]], + [c.strip() for c in row.split("│")[1:-1]], + ) + ) + assert cells == {"Row": "00", "Source": "tidal", "Media Type": "track", "ID": "3"} + + +def test_file_keeps_url_order_when_dropping_repeats(tmp_path, monkeypatch): + added = [] + + class FakeMain: + def __init__(self, _config): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + async def add_all(self, urls): + added.extend(urls) + + async def resolve(self): + pass + + async def rip(self): + pass + + monkeypatch.setattr("streamrip.rip.cli.Main", FakeMain) + urls = tmp_path / "urls.txt" + urls.write_text("https://c\nhttps://a\nhttps://c\nhttps://b\n") + result = CliRunner().invoke( + rip, ["--config-path", str(tmp_path / "config.toml"), "file", str(urls)] + ) + assert result.exit_code == 0, result.output + assert added == ["https://c", "https://a", "https://b"] diff --git a/tests/test_config.py b/tests/test_config.py index 44742b95..6034b149 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ import tomlkit from streamrip.config import ( + ArtistFilterConfig, ArtworkConfig, CliConfig, Config, @@ -18,10 +19,8 @@ MetadataConfig, MiscConfig, QobuzConfig, - QobuzDiscographyFilterConfig, SoundcloudConfig, TidalConfig, - YoutubeConfig, _get_dict_keys_r, _nested_set, update_config, @@ -130,15 +129,14 @@ def test_config_file_update(): assert toml["downloads"]["folder"] == "old_value" # type: ignore assert toml["downloads"]["source_subdirectories"] is True # type: ignore - assert toml["downloads"]["concurrency"] is True # type: ignore assert toml["downloads"]["max_connections"] == 6 # type: ignore assert toml["downloads"]["requests_per_minute"] == 60 # type: ignore - assert toml["cli"]["text_output"] is True # type: ignore assert toml["cli"]["progress_bars"] is True # type: ignore assert toml["cli"]["max_search_results"] == 100 # type: ignore - assert toml["misc"]["version"] == "2.2.0" # type: ignore - assert "YouTubeVideos" in str(toml["youtube"]["video_downloads_folder"]) - # type: ignore + assert toml["misc"]["version"] == "2.3.3" # type: ignore + # Options that no longer exist don't survive the update. + assert "youtube" not in toml + assert "text_output" not in toml["cli"] # type: ignore os.remove("tests/test_config_old2.toml") @@ -160,10 +158,10 @@ def test_sample_config_data_fields(sample_config_data): folder="test_folder", source_subdirectories=False, disc_subdirectories=True, - concurrency=True, max_connections=6, requests_per_minute=60, verify_ssl=True, + lyrics=True, ), qobuz=QobuzConfig( use_auth_token=False, @@ -181,24 +179,15 @@ def test_sample_config_data_fields(sample_config_data): refresh_token="refreshtoken", token_expiry="tokenexpiry", quality=3, - download_videos=True, ), deezer=DeezerConfig( arl="testarl", quality=2, lower_quality_if_not_available=True, - use_deezloader=True, - deezloader_warnings=True, ), soundcloud=SoundcloudConfig( client_id="clientid", app_version="appversion", - quality=0, - ), - youtube=YoutubeConfig( - video_downloads_folder="videodownloadsfolder", - quality=0, - download_videos=False, ), lastfm=LastFmConfig(source="qobuz", fallback_source=""), filepaths=FilepathsConfig( @@ -220,16 +209,14 @@ def test_sample_config_data_fields(sample_config_data): renumber_playlist_tracks=True, exclude=[], ), - qobuz_filters=QobuzDiscographyFilterConfig( + artist_filters=ArtistFilterConfig( extras=False, repeats=False, non_albums=False, features=False, - non_studio_albums=False, non_remaster=False, ), cli=CliConfig( - text_output=False, progress_bars=False, max_search_results=100, ), @@ -254,12 +241,11 @@ def test_sample_config_data_fields(sample_config_data): assert sample_config_data.tidal == test_config.tidal assert sample_config_data.deezer == test_config.deezer assert sample_config_data.soundcloud == test_config.soundcloud - assert sample_config_data.youtube == test_config.youtube assert sample_config_data.lastfm == test_config.lastfm assert sample_config_data.artwork == test_config.artwork assert sample_config_data.filepaths == test_config.filepaths assert sample_config_data.metadata == test_config.metadata - assert sample_config_data.qobuz_filters == test_config.qobuz_filters + assert sample_config_data.artist_filters == test_config.artist_filters assert sample_config_data.database == test_config.database assert sample_config_data.conversion == test_config.conversion @@ -289,7 +275,85 @@ def test_config_dont_update_without_set_modified(): assert conf2.session.downloads.folder == "test_folder" +def test_prefer_explicit_defaults_true(): + assert Config.defaults().session.metadata.prefer_explicit is True + + +def test_prefer_explicit_missing_from_toml_still_loads(): + # A config saved before this option existed has no `prefer_explicit` key + # in [metadata] at all; MetadataConfig's default must cover it so those + # configs keep loading without a version bump. + with open("streamrip/config.toml") as f: + toml_str = f.read().replace("\nprefer_explicit = true\n", "\n") + assert "prefer_explicit" not in toml_str + data = ConfigData.from_toml(toml_str) + assert data.metadata.prefer_explicit is True + + # Other tests for the Config class can be added as needed if __name__ == "__main__": pytest.main() + + +def test_merged_options_carry_over_on_update(tmp_path): + old = tomlkit.parse(open(OLD_CONFIG).read()) + old["downloads"]["concurrency"] = False # type: ignore + old["downloads"]["max_connections"] = 6 # type: ignore + old["qobuz_filters"]["non_studio_albums"] = True # type: ignore + old["qobuz_filters"]["repeats"] = True # type: ignore + path = tmp_path / "config.toml" + path.write_text(tomlkit.dumps(old)) + + Config._update_file(str(path), SAMPLE_CONFIG) + + new = tomlkit.parse(path.read_text()) + # concurrency = false became one download at a time. + assert new["downloads"]["max_connections"] == 1 # type: ignore + assert "concurrency" not in new["downloads"] # type: ignore + # The filters moved to [artist_filters]; non_studio_albums is part of extras. + assert "qobuz_filters" not in new + assert new["artist_filters"]["extras"] is True # type: ignore + assert new["artist_filters"]["repeats"] is True # type: ignore + + +def _update_with_tidal_login(tmp_path, hires_client: bool): + old = tomlkit.parse(open(OLD_CONFIG).read()) + tidal = old["tidal"] # type: ignore + tidal["hires_client"] = hires_client + tidal["client_id"] = "" + tidal["access_token"] = "tok" + tidal["refresh_token"] = "ref" + tidal["token_expiry"] = "1" + tidal["token_client_id"] = "some-client" + path = tmp_path / "config.toml" + path.write_text(tomlkit.dumps(old)) + + Config.update_file(str(path)) + return tomlkit.parse(path.read_text())["tidal"] # type: ignore + + +def test_hires_login_moves_to_its_own_fields_on_update(tmp_path): + # Before there was a second login, hires_client = true replaced the default + # client and its tokens lived in the ordinary fields. + tidal = _update_with_tidal_login(tmp_path, hires_client=True) + assert tidal["hires_access_token"] == "tok" + assert tidal["hires_refresh_token"] == "ref" + assert tidal["hires_token_expiry"] == "1" + assert tidal["hires_token_client_id"] == "some-client" + assert tidal["access_token"] == "" + assert tidal["token_client_id"] == "" + + +def test_default_client_login_stays_put_on_update(tmp_path): + tidal = _update_with_tidal_login(tmp_path, hires_client=False) + assert tidal["access_token"] == "tok" + assert tidal["token_client_id"] == "some-client" + assert tidal["hires_access_token"] == "" + + +def test_default_quality_is_the_highest_of_every_source(): + session = Config.defaults().session + assert session.qobuz.quality == 4 # 24-bit, up to 192 kHz + assert session.tidal.quality == 3 # best available, hi-res where there is one + assert session.deezer.quality == 2 # FLAC diff --git a/tests/test_config.toml b/tests/test_config.toml index a33a1864..e6188a8e 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -5,14 +5,9 @@ folder = "test_folder" source_subdirectories = false disc_subdirectories = true -# Download (and convert) tracks all at once, instead of sequentially. -# If you are converting the tracks, or have fast internet, this will -# substantially improve processing speed. -concurrency = true -# The maximum number of tracks to download at once -# If you have very fast internet, you will benefit from a higher value, -# A value that is too high for your bandwidth may cause slowdowns -# Set to -1 for no limit +# How many tracks to download at once. 1 downloads them one after another, +# -1 means no limit. More than your connection can carry only slows each +# download down. max_connections = 6 # Max number of API requests per source to handle per minute # Set to -1 for no limit @@ -20,6 +15,8 @@ requests_per_minute = 60 # Verify SSL certificates for API connections # Set to false if you encounter SSL certificate verification errors (not recommended) verify_ssl = true +# Download and embed lyrics (currently supported for Deezer and Tidal) +lyrics = true [qobuz] # 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>=96 @@ -41,8 +38,9 @@ secrets = ['secret1', 'secret2'] [tidal] # 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 "HiFi" FLAC, 3: 24/44.1 "MQA" FLAC quality = 3 -# This will download videos included in Video Albums. -download_videos = true +hires_client = false +client_id = "" +client_secret = "" # Do not change any of the fields below user_id = "userid" @@ -50,14 +48,14 @@ country_code = "countrycode" access_token = "accesstoken" refresh_token = "refreshtoken" # Tokens last 1 week after refresh. This is the Unix timestamp of the expiration -# time. If you haven't used streamrip in more than a week, you may have to log -# in again using `rip config --tidal` +# time. If you haven't used streamrip in more than a week, you may be asked to +# log in again. token_expiry = "tokenexpiry" +token_client_id = "" [deezer] -# 0, 1, or 2 -# This only applies to paid Deezer subscriptions. Those using deezloader -# are automatically limited to quality = 1 +# 0: 128kbps MP3, 1: 320kbps MP3, 2: FLAC. Limited to what the account's +# subscription allows. quality = 2 # If the target quality is not available, fallback to best quality available lower_quality_if_not_available = true @@ -65,28 +63,12 @@ lower_quality_if_not_available = true # See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie # for instructions on how to find this arl = "testarl" -# This allows for free 320kbps MP3 downloads from Deezer -# If an arl is provided, deezloader is never used -use_deezloader = true -# This warns you when the paid deezer account is not logged in and rip falls -# back to deezloader, which is unreliable -deezloader_warnings = true [soundcloud] -# Only 0 is available for now -quality = 0 -# This changes periodically, so it needs to be updated +# Found and kept up to date by streamrip. Leave these alone. client_id = "clientid" app_version = "appversion" -[youtube] -# Only 0 is available for now -quality = 0 -# Download the video along with the audio -download_videos = false -# The path to download the videos to -video_downloads_folder = "videodownloadsfolder" - [database] # Create a database that contains all the track IDs downloaded so far # Any time a track logged in the database is requested, it is skipped @@ -94,7 +76,7 @@ video_downloads_folder = "videodownloadsfolder" downloads_enabled = true # Path to the downloads database downloads_path = "downloadspath" -# If a download fails, the item ID is stored here. Then, `rip repair` can be +# If a download fails, the item ID is stored here. Then, `streamrip repair` can be # called to retry the downloads failed_downloads_enabled = true failed_downloads_path = "faileddownloadspath" @@ -113,18 +95,20 @@ bit_depth = 24 # Only applicable for lossy codecs lossy_bitrate = 320 -# Filter a Qobuz artist's discography. Set to 'true' to turn on a filter. -[qobuz_filters] -# Remove Collectors Editions, live recordings, etc. +# Filters for downloading an artist's discography, from any source. +# Set one to true to turn it on. +[artist_filters] +# Skip special editions (deluxe, anniversary, collector's, expanded), live +# albums, demos, remixes and various-artists compilations extras = false -# Picks the highest quality out of albums with identical titles. +# Of albums with the same title (ignoring anything in brackets), only +# download the best quality one repeats = false -# Remove EPs and Singles +# Skip singles (releases with only one track) non_albums = false -# Remove albums whose artist is not the one requested +# Skip albums credited to someone else, such as ones the artist only +# features on features = false -# Skip non studio albums -non_studio_albums = false # Only download remastered albums non_remaster = false @@ -155,6 +139,9 @@ renumber_playlist_tracks = true # The following metadata tags won't be applied # See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info exclude = [] +# When an album or playlist has both a clean and an explicit copy of the same +# track, skip the clean one and only download the explicit copy. +prefer_explicit = true # Changes the folder and file names generated by streamrip. [filepaths] @@ -169,8 +156,8 @@ folder_format = "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{s track_format = "{tracknumber}. {artist} - {title}{explicit}" # Only allow printable ASCII characters in filenames. restrict_characters = false -# Truncate the filename if it is greater than this number of characters -# Setting this to false may cause downloads to fail on some systems +# Shorten track file names to this many characters (0: no limit). Names are +# always shortened as far as the file system needs, whatever this is set to. truncate_to = 120 # Last.fm playlists are downloaded by searching for the titles of the tracks @@ -182,8 +169,6 @@ source = "qobuz" fallback_source = "" [cli] -# Print "Downloading {Album name}" etc. to screen -text_output = true # Show resolve, download progress bars progress_bars = true # The maximum number of search results to show in the interactive menu @@ -191,5 +176,5 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.2.0" +version = "2.3.3" check_for_updates = true diff --git a/tests/test_config_old.toml b/tests/test_config_old.toml index 018c5ea3..f70dc220 100644 --- a/tests/test_config_old.toml +++ b/tests/test_config_old.toml @@ -32,7 +32,7 @@ access_token = "old_accesstoken" refresh_token = "old_refreshtoken" # Tokens last 1 week after refresh. This is the Unix timestamp of the expiration # time. If you haven't used streamrip in more than a week, you may have to log -# in again using `rip config --tidal` +# in again using `streamrip config --tidal` token_expiry = "old_tokenexpiry" [deezer] @@ -47,7 +47,7 @@ arl = "old_testarl" # This allows for free 320kbps MP3 downloads from Deezer # If an arl is provided, deezloader is never used use_deezloader = true -# This warns you when the paid deezer account is not logged in and rip falls +# This warns you when the paid deezer account is not logged in and streamrip falls # back to deezloader, which is unreliable deezloader_warnings = true @@ -71,7 +71,7 @@ download_videos = false downloads_enabled = true # Path to the downloads database downloads_path = "old_downloadspath" -# If a download fails, the item ID is stored here. Then, `rip repair` can be +# If a download fails, the item ID is stored here. Then, `streamrip repair` can be # called to retry the downloads failed_downloads_enabled = true failed_downloads_path = "old_faileddownloadspath" diff --git a/tests/test_converter.py b/tests/test_converter.py new file mode 100644 index 00000000..d164bb54 --- /dev/null +++ b/tests/test_converter.py @@ -0,0 +1,124 @@ +import os +import shutil + +import mutagen +import pytest +from util import arun + +from streamrip import converter +from streamrip.config import Config +from streamrip.exceptions import ConversionError +from streamrip.media.track import Track +from streamrip.utils.ffmpeg_utils import find_ffmpeg + + +class DummyConverter: + lossless = False + instances = [] + + @classmethod + def get_quality_arg(cls, rate): + return f"-b:a {rate}k" + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.final_fn = "/tmp/output.opus" + self.instances.append(self) + + async def convert(self): + pass + + +class DummyLosslessConverter(DummyConverter): + lossless = True + instances = [] + + @classmethod + def get_quality_arg(cls, rate): + raise AssertionError("lossless converters should not use lossy_bitrate") + + +def test_lossy_bitrate_config_is_passed_to_converter(monkeypatch): + monkeypatch.setattr("streamrip.media.track.converter.get", lambda _: DummyConverter) + DummyConverter.instances = [] + + config = Config.defaults() + config.session.conversion.codec = "OPUS" + config.session.conversion.lossy_bitrate = 160 + track = Track(None, None, config, "", None, None, download_path="/tmp/source.flac") + + arun(track._convert()) + + engine = DummyConverter.instances[0] + assert engine.kwargs["ffmpeg_arg"] == "-b:a 160k" + assert track.download_path == "/tmp/output.opus" + + +def test_lossy_bitrate_config_is_not_passed_to_lossless_converter(monkeypatch): + monkeypatch.setattr( + "streamrip.media.track.converter.get", lambda _: DummyLosslessConverter + ) + DummyLosslessConverter.instances = [] + + config = Config.defaults() + config.session.conversion.codec = "ALAC" + config.session.conversion.lossy_bitrate = 160 + track = Track(None, None, config, "", None, None, download_path="/tmp/source.flac") + + arun(track._convert()) + + engine = DummyLosslessConverter.instances[0] + assert engine.kwargs["ffmpeg_arg"] is None + + +def test_lossy_codec_quality_args_use_configured_bitrate(): + assert converter.OPUS.get_quality_arg(160) == "-b:a 160k" + assert converter.AAC.get_quality_arg(192) == "-b:a 192k" + assert converter.LAME.get_quality_arg(160) == "-b:a 160k" + assert converter.Vorbis.get_quality_arg(160) == "-qscale:a 5" + + +needs_ffmpeg = pytest.mark.skipif(find_ffmpeg() is None, reason="needs ffmpeg") + + +@needs_ffmpeg +@pytest.mark.parametrize( + "codec, extension", + [ + ("FLAC", "flac"), + ("ALAC", "m4a"), + ("AIFF", "aiff"), + ("MP3", "mp3"), + ("AAC", "m4a"), + ("OPUS", "opus"), + ("OGG", "ogg"), + ], +) +def test_conversion(tmp_path, codec, extension): + source = tmp_path / "track.wav" + shutil.copy("tests/silence.flac", tmp_path / "track.flac") + (tmp_path / "track.flac").rename(source) # any input ffmpeg can read + engine = converter.get(codec)( + str(source), sampling_rate=48000, bit_depth=16, remove_source=True + ) + + arun(engine.convert()) + + assert engine.final_fn == str(tmp_path / f"track.{extension}") + assert mutagen.File(engine.final_fn) is not None + assert not source.exists() + + +@needs_ffmpeg +def test_failed_conversion_says_why_and_cleans_up(tmp_path): + source = tmp_path / "track.flac" + source.write_bytes(b"not audio") + engine = converter.LAME(str(source), remove_source=True) + + with pytest.raises(ConversionError, match="ffmpeg failed") as err: + arun(engine.convert()) + + # ffmpeg's own words, not just that it failed (the wording varies). + assert str(err.value).split("): ", 1)[1].strip() + assert source.exists() + assert not os.path.exists(engine.tempfile) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..399dcea8 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,31 @@ +from streamrip import db + + +def test_clear_removes_every_row_and_reports_how_many(tmp_path): + downloads = db.Downloads(str(tmp_path / "downloads.db")) + for track_id in ("1", "2", "3"): + downloads.add((track_id,)) + + assert downloads.clear() == 3 + assert downloads.all() == [] + assert downloads.clear() == 0 + + +def test_a_cleared_database_is_usable_again(tmp_path): + downloads = db.Downloads(str(tmp_path / "downloads.db")) + downloads.add(("1",)) + downloads.clear() + + assert not downloads.contains(id="1") + downloads.add(("1",)) + assert downloads.contains(id="1") + + +def test_remove_deletes_only_the_matching_row(tmp_path): + failed = db.Failed(str(tmp_path / "failed.db")) + failed.add(("tidal", "track", "1")) + failed.add(("qobuz", "album", "2")) + + failed.remove(id="1") + + assert failed.all() == [("qobuz", "album", "2")] diff --git a/tests/test_deezer.py b/tests/test_deezer.py index 5b7b7d77..c8567c1a 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -1,13 +1,14 @@ import os +from unittest.mock import Mock + import pytest -from unittest.mock import Mock, patch from util import arun -from streamrip.client.downloadable import DeezerDownloadable from streamrip.client.deezer import DeezerClient from streamrip.config import Config from streamrip.exceptions import NonStreamableError + @pytest.fixture(scope="session") def deezer_client(): """Integration test fixture - requires DEEZER_ARL environment variable""" @@ -17,11 +18,12 @@ def deezer_client(): config.session.deezer.lower_quality_if_not_available = True client = DeezerClient(config) arun(client.login()) - + yield client - + arun(client.session.close()) + @pytest.fixture def mock_deezer_client(): """Unit test fixture - mocked client for fast testing""" @@ -29,103 +31,235 @@ def mock_deezer_client(): config.session.deezer.arl = "test_arl" config.session.deezer.quality = 2 config.session.deezer.lower_quality_if_not_available = True - + client = DeezerClient(config) client.client = Mock() client.client.gw = Mock() client.session = Mock() - + return client + # ===== UNIT TESTS ===== + def test_deezer_fallback_logic_with_mock_data(mock_deezer_client): """Unit test: fallback logic works with mocked track data""" # Mock track info where FLAC is unavailable but MP3_320 is available # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] # So FILESIZE_MP3_128 = quality 0, FILESIZE_MP3_320 = quality 1, FILESIZE_FLAC = quality 2 mock_track_info = { - "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) - "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) - "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) - "TRACK_TOKEN": "test_token" + "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) + "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) + "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) + "TRACK_TOKEN": "test_token", } - + # Mock the client methods mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = "https://test.mp3" - + # Test fallback behavior - with patch.object(mock_deezer_client, 'get_session'): - downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) - - # Should have fallen back to quality 1 (MP3_320) since FLAC is unavailable - assert downloadable.quality == 1 + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # Should have fallen back to quality 1 (MP3_320) since FLAC is unavailable + assert downloadable.quality == 1 + def test_deezer_no_fallback_when_quality_available(mock_deezer_client): """Unit test: no fallback when requested quality is available""" # Mock track info where FLAC is available # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] mock_track_info = { - "FILESIZE_FLAC": 25000000, # FLAC available (quality 2) + "FILESIZE_FLAC": 25000000, # FLAC available (quality 2) "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) - "TRACK_TOKEN": "test_token" + "TRACK_TOKEN": "test_token", } - + mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = "https://test.flac" - - with patch.object(mock_deezer_client, 'get_session'): - downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) - - # Should use requested quality 2 (FLAC) - assert downloadable.quality == 2 + + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # Should use requested quality 2 (FLAC) + assert downloadable.quality == 2 + def test_deezer_fallback_to_lowest_available_quality(mock_deezer_client): """Unit test: fallback walks down quality list until finding available quality""" # Mock track info where only MP3_128 is available # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] mock_track_info = { - "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) - "FILESIZE_MP3_320": 0, # MP3_320 unavailable (quality 1) - "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) - "TRACK_TOKEN": "test_token" + "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) + "FILESIZE_MP3_320": 0, # MP3_320 unavailable (quality 1) + "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) + "TRACK_TOKEN": "test_token", } - + mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = "https://test.mp3" - - with patch.object(mock_deezer_client, 'get_session'): - downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) - - # Should have fallen back to quality 0 (MP3_128) since higher qualities unavailable - assert downloadable.quality == 0 + + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # Should have fallen back to quality 0 (MP3_128) since higher qualities unavailable + assert downloadable.quality == 0 + + +def test_deezer_no_url_raises_instead_of_building_legacy_cdn_url(mock_deezer_client): + """Unit test: a missing download URL fails immediately. + + get_track_url returning None used to fall back to a generated + e-cdns-proxy-.dzcdn.net URL. Deezer retired that CDN, so the URL was + guaranteed to fail at download time. + """ + mock_track_info = { + "FILESIZE_FLAC": 25000000, + "FILESIZE_MP3_320": 5000000, + "FILESIZE_MP3_128": 2000000, + "TRACK_TOKEN": "test_token", + "MD5_ORIGIN": "abc123def456abc123def456abc12345", + "MEDIA_VERSION": "1", + } + + mock_deezer_client.client.gw.get_track.return_value = mock_track_info + mock_deezer_client.client.get_track_url.return_value = None + + with pytest.raises(NonStreamableError, match="legacy CDN"): + arun(mock_deezer_client.get_downloadable("123", quality=2)) + + +def test_deezer_no_url_error_does_not_leak_a_cdn_url(mock_deezer_client): + """Unit test: no code path may still produce an e-cdns-proxy URL.""" + mock_track_info = { + "FILESIZE_FLAC": 25000000, + "FILESIZE_MP3_320": 5000000, + "FILESIZE_MP3_128": 2000000, + "TRACK_TOKEN": "test_token", + "MD5_ORIGIN": "abc123def456abc123def456abc12345", + "MEDIA_VERSION": "1", + } + + mock_deezer_client.client.gw.get_track.return_value = mock_track_info + mock_deezer_client.client.get_track_url.return_value = None + + with pytest.raises(NonStreamableError) as excinfo: + arun(mock_deezer_client.get_downloadable("123", quality=2)) + + assert "e-cdns-proxy" not in str(excinfo.value) + + +def test_deezer_no_url_follows_fallback_track(mock_deezer_client): + """Unit test: a delisted track is recovered through FALLBACK.SNG_ID. + + Old-catalog tracks report FILESIZE_* = 0 for every tier and get no URL at + any quality -- they have been superseded rather than made unavailable, and + Deezer names the superseding release in FALLBACK. These are exactly the + tracks that used to reach the retired CDN. + """ + + def gw_get_track(track_id): + if track_id == "123": + return { + "FILESIZE_FLAC": 0, + "FILESIZE_MP3_320": 0, + "FILESIZE_MP3_128": 0, + "TRACK_TOKEN": "token_123", + "FALLBACK": {"SNG_ID": "456"}, + } + return { + "FILESIZE_FLAC": 25000000, + "FILESIZE_MP3_320": 5000000, + "FILESIZE_MP3_128": 2000000, + "TRACK_TOKEN": "token_456", + } + + mock_deezer_client.client.gw.get_track.side_effect = gw_get_track + mock_deezer_client.client.get_track_url.side_effect = lambda token, fmt: ( + None if token == "token_123" else "https://test.flac" + ) + + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # The bytes are the fallback track's, so the id -- from which the Blowfish + # key is derived -- must follow it, not the originally requested track. + assert downloadable.id == "456" + # The fallback is asked for at the requested quality, not at the one the + # zeroed FILESIZEs of the delisted track downgraded to. + assert downloadable.quality == 2 + + +def test_deezer_fallback_is_not_followed_twice(mock_deezer_client): + """Unit test: a fallback that itself resolves nowhere raises, not recurses. + + Deezer's FALLBACK chains can point at another dead entry. + """ + mock_deezer_client.client.gw.get_track.return_value = { + "FILESIZE_FLAC": 25000000, + "FILESIZE_MP3_320": 5000000, + "FILESIZE_MP3_128": 2000000, + "TRACK_TOKEN": "test_token", + "FALLBACK": {"SNG_ID": "456"}, + } + mock_deezer_client.client.get_track_url.return_value = None + + with pytest.raises(NonStreamableError, match="delisted"): + arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # Once for the requested track, once for the fallback - never a third time. + assert mock_deezer_client.client.gw.get_track.call_count == 2 + def test_deezer_no_fallback_when_disabled(mock_deezer_client): """Unit test: no fallback when lower_quality_if_not_available is False""" # Disable fallback mock_deezer_client.config.lower_quality_if_not_available = False - + # Mock track info where FLAC is unavailable # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] mock_track_info = { - "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) - "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) - "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) - "TRACK_TOKEN": "test_url" + "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) + "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) + "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) + "TRACK_TOKEN": "test_url", } - + mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = "https://test.mp3" - + # Should raise an error when requested quality is unavailable and fallback is disabled - with patch.object(mock_deezer_client, 'get_session'): - with pytest.raises(NonStreamableError, match="The requested quality 2 is not available and fallback is disabled"): - arun(mock_deezer_client.get_downloadable("123", quality=2)) + with pytest.raises( + NonStreamableError, + match="The requested quality 2 is not available and fallback is disabled", + ): + arun(mock_deezer_client.get_downloadable("123", quality=2)) + + +def test_deezer_album_cache(mock_deezer_client): + """Unit test: verify get_album results are cached and retrieved on subsequent calls""" + mock_deezer_client.client.api.get_album.return_value = { + "id": "album_123", + "title": "Test Album", + "genres": {"data": []}, + } + mock_deezer_client.client.api.get_album_tracks.return_value = {"data": []} + + # Call get_album twice + res1 = arun(mock_deezer_client.get_album("album_123")) + res2 = arun(mock_deezer_client.get_album("album_123")) + + # Assert return values are identical + assert res1 == res2 + assert res1["title"] == "Test Album" + + # Assert api call was made exactly once + assert mock_deezer_client.client.api.get_album.call_count == 1 + assert mock_deezer_client.client.api.get_album_tracks.call_count == 1 + # ===== INTEGRATION TEST ===== + @pytest.mark.skipif( "DEEZER_ARL" not in os.environ, reason="Deezer ARL not found in env." ) @@ -133,12 +267,14 @@ def test_deezer_fallback_actually_occurred(deezer_client): """Integration test: verify fallback works with real track 77874822""" # We know track 77874822 doesn't have FLAC available, so test fallback scenario downloadable = arun(deezer_client.get_downloadable("77874822", quality=2)) - + # Since we requested FLAC (quality=2) but it's not available, # we should have fallen back to the next available quality (1 = MP3_320) - assert downloadable.quality == 1, "Should have fallen back to MP3_320 when FLAC unavailable" + assert downloadable.quality == 1, ( + "Should have fallen back to MP3_320 when FLAC unavailable" + ) print("Fallback occurred: FLAC unavailable, fell back to MP3_320") - + # Verify the URL is actually accessible and working assert downloadable.url.startswith("https://") assert downloadable._size > 0, "Downloadable should have a valid file size" diff --git a/tests/test_deezer_client.py b/tests/test_deezer_client.py new file mode 100644 index 00000000..600f3830 --- /dev/null +++ b/tests/test_deezer_client.py @@ -0,0 +1,100 @@ +"""Unit tests for Deezer client behaviour that needs no network or ARL.""" + +from unittest.mock import Mock + +import pytest + +from streamrip.client.deezer import DeezerClient +from streamrip.config import Config +from streamrip.exceptions import NonStreamableError + +TRACK_INFO = { + "FILESIZE_FLAC": 30_000_000, + "FILESIZE_MP3_320": 9_000_000, + "FILESIZE_MP3_128": 4_000_000, + "TRACK_TOKEN": "token", +} + + +def _client(lower_quality=True, **account) -> DeezerClient: + config = Config.defaults() + config.session.deezer.lower_quality_if_not_available = lower_quality + client = DeezerClient(config) + client.client = Mock() + client.client.current_user = {"license_token": "x", **account} + client.client.gw.get_track.return_value = dict(TRACK_INFO) + client.client.get_track_url.return_value = "https://cdn.example/track" + client.session = Mock() + return client + + +@pytest.mark.asyncio +async def test_free_account_is_clamped_to_mp3_128(): + """Upstream #1015: quality 1 on a free account used to claim HiFi was needed.""" + client = _client(can_stream_hq=False, can_stream_lossless=False) + dl = await client.get_downloadable("1", quality=1) + assert dl.quality == 0 + client.client.get_track_url.assert_called_once_with("token", "MP3_128") + + +@pytest.mark.asyncio +async def test_premium_account_is_clamped_to_mp3_320(): + client = _client(can_stream_hq=True, can_stream_lossless=False) + dl = await client.get_downloadable("1", quality=2) + assert dl.quality == 1 and dl.extension == "mp3" + + +@pytest.mark.asyncio +async def test_clamp_can_be_disabled(): + client = _client(lower_quality=False, can_stream_hq=False) + with pytest.raises(NonStreamableError, match="subscription"): + await client.get_downloadable("1", quality=1) + + +@pytest.mark.asyncio +async def test_out_of_range_quality_does_not_crash(): + """`streamrip --quality 4` used to raise IndexError for Deezer.""" + client = _client(can_stream_hq=True, can_stream_lossless=True) + dl = await client.get_downloadable("1", quality=4) + assert dl.quality == 2 and dl.extension == "flac" + + +@pytest.mark.asyncio +async def test_playlist_falls_back_to_gw_api(): + client = _client() + client.client.api.get_playlist.side_effect = Exception("PermissionException") + client.client.gw.get_playlist_page.return_value = {"DATA": {"TITLE": "Mine"}} + client.client.gw.get_playlist_tracks.return_value = [ + {"SNG_ID": "11"}, + {"SNG_ID": "12"}, + ] + resp = await client.get_playlist("42") + assert resp["title"] == "Mine" + assert [t["id"] for t in resp["tracks"]] == ["11", "12"] + + +@pytest.mark.asyncio +async def test_redirected_album_id_is_followed(): + """Upstream #893: /album//tracks has no data, the new id does.""" + client = _client() + client.client.api.get_album.return_value = {"id": 723513301, "title": "New"} + + def tracks(album_id): + if str(album_id) == "997825": + raise Exception("DataException: no data") + return {"data": [{"id": 1, "disk_number": 1}]} + + client.client.api.get_album_tracks.side_effect = tracks + resp = await client.get_album("997825") + assert resp["track_total"] == 1 + + +def test_synced_lyrics_are_formatted_as_lrc(): + lrc = DeezerClient._format_synced_lyrics( + [ + {"lrc_timestamp": "[00:01.00]", "line": "Hello"}, + {"line": ""}, + {"lrc_timestamp": "[00:05.50]", "line": "World"}, + ] + ) + assert lrc == "[00:01.00]Hello\n\n[00:05.50]World" diff --git a/tests/test_deezer_decrypt.py b/tests/test_deezer_decrypt.py new file mode 100644 index 00000000..c4b1139f --- /dev/null +++ b/tests/test_deezer_decrypt.py @@ -0,0 +1,118 @@ +import os + +import pytest +from Cryptodome.Cipher import Blowfish + +from streamrip.client.downloadable import DEEZER_STRIPE, DeezerDownloadable + +IV = b"\x00\x01\x02\x03\x04\x05\x06\x07" + + +def _encrypt(key: bytes, plain: bytes) -> bytes: + """What Deezer serves: the first 2048 bytes of every stripe encrypted.""" + out = bytearray(plain) + for i in range(0, len(out), DEEZER_STRIPE): + if len(out) - i >= 2048: + block = bytes(out[i : i + 2048]) + out[i : i + 2048] = Blowfish.new(key, Blowfish.MODE_CBC, IV).encrypt(block) + return bytes(out) + + +class _Content: + def __init__(self, body: bytes, sizes: list[int]): + self.body, self.sizes = body, sizes + + async def iter_chunked(self, _n): + # Irregular sizes, so stripes straddle the chunks the server sends. + pos, i = 0, 0 + while pos < len(self.body): + size = self.sizes[i % len(self.sizes)] + yield self.body[pos : pos + size] + pos, i = pos + size, i + 1 + + +class _Resp: + def __init__(self, body: bytes): + self.headers = {"Content-Length": str(len(body))} + self.content = _Content(body, [1000, 7000, 333, 20000]) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + def raise_for_status(self): + pass + + +class _Session: + def __init__(self, body: bytes): + self.body = body + + def get(self, _url, **_): + return _Resp(self.body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("length", [5 * DEEZER_STRIPE + 3000, 5 * DEEZER_STRIPE + 100]) +async def test_encrypted_stream_decrypts_to_the_original(tmp_path, length): + plain = os.urandom(length) + info = { + "url": "https://cdn.example/mobile/1/track", + "quality_to_size": [0, 0, length], + "quality": 2, + "id": "3135556", + } + key = DeezerDownloadable._generate_blowfish_key(info["id"]) + downloadable = DeezerDownloadable(_Session(_encrypt(key, plain)), info) + received = [] + + await downloadable._download(str(tmp_path / "t.flac"), received.append) + + assert (tmp_path / "t.flac").read_bytes() == plain + assert sum(received) == length + + +class _SmallResp(_Resp): + def __init__(self, body: bytes): + super().__init__(body) + self.raw = body + + async def json(self, content_type=None): + import json + + return json.loads(self.raw) + + +class _SmallSession: + def __init__(self, body: bytes): + self.body = body + + def get(self, _url, **_): + return _SmallResp(self.body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body, message", + [ + (b'{"error": "RIGHTS", "message": "Not available"}', "RIGHTS - Not available"), + (b"gone", "File not found."), + ], +) +async def test_an_error_body_instead_of_audio_is_non_streamable( + tmp_path, body, message +): + from streamrip.exceptions import NonStreamableError + + info = { + "url": "https://cdn.example/mobile/1/track", + "quality_to_size": [0, 0, 5_000_000], + "quality": 2, + "id": "1", + } + downloadable = DeezerDownloadable(_SmallSession(body), info) + + with pytest.raises(NonStreamableError, match=message): + await downloadable._download(str(tmp_path / "t.flac"), lambda _: None) diff --git a/tests/test_discography_filter.py b/tests/test_discography_filter.py index 0a5d608d..d4a0f71e 100644 --- a/tests/test_discography_filter.py +++ b/tests/test_discography_filter.py @@ -1,5 +1,6 @@ from typing import Optional +from streamrip.config import ArtistFilterConfig from streamrip.media import Album, Artist from streamrip.metadata import AlbumInfo, AlbumMetadata @@ -115,3 +116,40 @@ def test_missing_values(): assert len(result) == 1 # explicit true wins over false when other quality metrics are equal (or missing) assert result[0] == album2 + + +def test_title_starting_with_a_bracket(): + # Used to trip an assertion and abort the whole artist download. + album1 = create_album("(What's the Story) Morning Glory?", False, 44.1, 16, id="a1") + album2 = create_album("[Untitled]", False, 44.1, 16, id="a2") + album3 = create_album("(What's the Story) Morning Glory?", False, 96, 24, id="a3") + result = Artist._filter_repeats([album1, album2, album3]) + assert {a.meta.info.id for a in result} == {"a2", "a3"} + + +def test_non_albums_filter_skips_single_track_releases(): + single = create_album("Single", False, 44.1, 16, id="s") + single.meta.tracktotal = 1 + album = create_album("Album", False, 44.1, 16, id="a") + artist = Artist(name="artist", albums=[], client=None, config=None) # type: ignore + filters = ArtistFilterConfig( + extras=False, + repeats=False, + non_albums=True, + features=False, + non_remaster=False, + ) + assert not artist._wanted(single, filters) + assert artist._wanted(album, filters) + + +def test_extras_filter_skips_various_artists_compilations(): + compilation = create_album("Summer Hits", False, 44.1, 16, id="c") + compilation.meta.albumartist = "Various Artists" + album = create_album("Album", False, 44.1, 16, id="a") + artist = Artist(name="artist", albums=[], client=None, config=None) # type: ignore + filters = ArtistFilterConfig( + extras=True, repeats=False, non_albums=False, features=False, non_remaster=False + ) + assert not artist._wanted(compilation, filters) + assert artist._wanted(album, filters) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 67f6a6e7..8e98abdb 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -11,9 +11,11 @@ class TestErrorHandling: """Test error handling in playlist and album downloads.""" @pytest.mark.asyncio - async def test_playlist_handles_failed_track(self): + @pytest.mark.parametrize("prefer_explicit", [False, True]) + async def test_playlist_handles_failed_track(self, prefer_explicit): """Test that a playlist download continues even if one track fails.""" mock_config = MagicMock() + mock_config.session.metadata.prefer_explicit = prefer_explicit mock_client = MagicMock() mock_track_success = MagicMock() @@ -39,11 +41,16 @@ async def test_playlist_handles_failed_track(self): mock_track_failure.resolve.assert_called_once() @pytest.mark.asyncio - async def test_album_handles_failed_track(self): + @pytest.mark.parametrize("prefer_explicit", [False, True]) + async def test_album_handles_failed_track(self, prefer_explicit): """Test that an album download continues even if one track fails.""" mock_config = MagicMock() + mock_config.session.metadata.prefer_explicit = prefer_explicit mock_db = MagicMock() mock_meta = MagicMock() + mock_meta.info.container = "FLAC" + mock_meta.info.bit_depth = None + mock_meta.info.sampling_rate = None # Create a list of mock tracks - one will succeed, one will fail mock_track_success = MagicMock() @@ -103,3 +110,54 @@ async def test_main_rip_handles_failed_media(self): mock_media_success.rip.assert_called_once() mock_media_failure.rip.assert_called_once() + + @pytest.mark.asyncio + async def test_main_resolve_handles_a_failing_item(self, caplog): + """One URL that fails to resolve must not stop the others.""" + from streamrip.rip.main import Main + + mock_config = MagicMock() + mock_config.session.database.downloads_enabled = False + mock_config.session.database.failed_downloads_enabled = False + + with ( + patch("streamrip.rip.main.QobuzClient"), + patch("streamrip.rip.main.TidalClient"), + patch("streamrip.rip.main.DeezerClient"), + patch("streamrip.rip.main.SoundcloudClient"), + ): + main = Main(mock_config) + + ok = MagicMock() + ok.resolve = AsyncMock(return_value="album") + broken = MagicMock() + broken.id = "123" + broken.resolve = AsyncMock(side_effect=KeyError("tracks")) + main.pending = [broken, ok] + + await main.resolve() + + assert main.media == ["album"] + assert "Error resolving 123: KeyError" in caplog.text + + @pytest.mark.asyncio + async def test_search_page_without_results_is_not_a_crash(self): + """A search can return a page with nothing in it; that's no results.""" + from streamrip.rip.main import Main + + mock_config = MagicMock() + mock_config.session.database.downloads_enabled = False + mock_config.session.database.failed_downloads_enabled = False + + with ( + patch("streamrip.rip.main.QobuzClient"), + patch("streamrip.rip.main.TidalClient"), + patch("streamrip.rip.main.DeezerClient"), + patch("streamrip.rip.main.SoundcloudClient"), + ): + main = Main(mock_config) + main.clients["deezer"].search = AsyncMock(return_value=[{"data": []}]) + + await main.search_take_first("deezer", "track", "nothing matches") + + assert main.pending == [] diff --git a/tests/test_ffmpeg_utils.py b/tests/test_ffmpeg_utils.py new file mode 100644 index 00000000..9c1ba5cf --- /dev/null +++ b/tests/test_ffmpeg_utils.py @@ -0,0 +1,36 @@ +import sys +import types +from unittest.mock import patch + +from streamrip.utils.ffmpeg_utils import find_ffmpeg + + +def _clear_cache(): + find_ffmpeg.cache_clear() + + +def test_prefers_ffmpeg_on_path(): + _clear_cache() + with patch("shutil.which", return_value="/usr/bin/ffmpeg") as which: + assert find_ffmpeg() == "/usr/bin/ffmpeg" + which.assert_called_once_with("ffmpeg") + + +def test_falls_back_to_imageio_ffmpeg_when_not_on_path(): + _clear_cache() + fake_module = types.ModuleType("imageio_ffmpeg") + fake_module.get_ffmpeg_exe = lambda: "/fake/imageio_ffmpeg/ffmpeg" + with ( + patch("shutil.which", return_value=None), + patch.dict(sys.modules, {"imageio_ffmpeg": fake_module}), + ): + assert find_ffmpeg() == "/fake/imageio_ffmpeg/ffmpeg" + + +def test_returns_none_when_nothing_is_available(): + _clear_cache() + with ( + patch("shutil.which", return_value=None), + patch.dict(sys.modules, {"imageio_ffmpeg": None}), + ): + assert find_ffmpeg() is None diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100644 index 00000000..747c6bfb --- /dev/null +++ b/tests/test_media.py @@ -0,0 +1,61 @@ +from streamrip.media.media import filter_prefer_explicit +from streamrip.metadata import ( + AlbumInfo, + AlbumMetadata, + Covers, + TrackInfo, + TrackMetadata, +) + + +class FakeResolvedTrack: + """Stands in for a resolved `Track`: filter_prefer_explicit only looks at + `.meta`, so a real `Track` (with a live downloadable, config, db, ...) + would be unnecessary weight here. + """ + + def __init__(self, title, artist, explicit): + info = TrackInfo(id="1", explicit=explicit) + album = AlbumMetadata( + AlbumInfo("1", 2, "flac"), "Album", artist, "2020", [], Covers(), 1 + ) + self.meta = TrackMetadata( + info=info, + title=title, + album=album, + artist=artist, + tracknumber=1, + discnumber=1, + composer=None, + ) + + +def test_prefer_explicit_drops_clean_duplicate(): + clean = FakeResolvedTrack("Song", "Artist", False) + explicit = FakeResolvedTrack("Song", "Artist", True) + assert filter_prefer_explicit([clean, explicit]) == [explicit] + + +def test_prefer_explicit_keeps_clean_only(): + clean = FakeResolvedTrack("Song", "Artist", False) + assert filter_prefer_explicit([clean]) == [clean] + + +def test_prefer_explicit_keeps_explicit_only(): + explicit = FakeResolvedTrack("Song", "Artist", True) + assert filter_prefer_explicit([explicit]) == [explicit] + + +def test_prefer_explicit_leaves_unrelated_tracks_alone(): + clean = FakeResolvedTrack("Song", "Artist", False) + explicit = FakeResolvedTrack("Song", "Artist", True) + other = FakeResolvedTrack("Other Song", "Artist", False) + result = filter_prefer_explicit([clean, explicit, other]) + assert set(result) == {explicit, other} + + +def test_prefer_explicit_matches_title_and_artist_loosely(): + # Same song, different casing/whitespace -- still recognized as a dupe. + clean = FakeResolvedTrack(" song ", "ARTIST", False) + explicit = FakeResolvedTrack("Song", "artist", True) + assert filter_prefer_explicit([clean, explicit]) == [explicit] diff --git a/tests/test_meta.py b/tests/test_meta.py index 01032baa..54014179 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -21,7 +21,9 @@ def test_album_metadata_qobuz(): assert info.bit_depth == 24 assert info.booklets is None - assert m.album == "Rumours" + # The edition ("version") is kept in the title so editions do not collide. + assert m.album == "Rumours (2001 Remaster)" + assert m.version == "2001 Remaster" assert m.albumartist == "Fleetwood Mac" assert m.year == "1977" assert "Pop" in m.genre @@ -29,7 +31,6 @@ def test_album_metadata_qobuz(): assert not m.covers.empty() assert m.albumcomposer == "Various Composers" - assert m.comment is None assert m.compilation is None assert ( m.copyright @@ -38,10 +39,6 @@ def test_album_metadata_qobuz(): assert m.date == "1977-02-04" assert m.description == "" assert m.disctotal == 1 - assert m.encoder is None - assert m.grouping is None - assert m.lyrics is None - assert m.purchase_date is None assert m.tracktotal == 11 @@ -50,10 +47,8 @@ def test_track_metadata_qobuz(): t = TrackMetadata.from_qobuz(a, qobuz_track_resp) info = t.info assert info.id == "216020864" - assert info.quality == 3 - assert info.bit_depth == 24 - assert info.sampling_rate == 96 - assert info.work is None + assert info.explicit is False + assert t.isrc == "USMRG2384109" assert t.title == "Water Tower" assert t.album == a @@ -61,3 +56,160 @@ def test_track_metadata_qobuz(): assert t.tracknumber == 9 assert t.discnumber == 1 assert t.composer == "John Darnielle" + + +def _tidal_album(**extra): + return { + "id": 10, + "title": "Album", + "allowStreaming": True, + "audioQuality": "LOSSLESS", + "artists": [{"name": "A"}, {"name": "B"}], + "numberOfTracks": 9, + "numberOfVolumes": 2, + "releaseDate": "2019-05-01", + "cover": "ab-cd", + **extra, + } + + +def test_tidal_album_with_null_copyright_and_no_date(): + # The fix for a null copyright (upstream #979) only reached the copy of + # this parser used for single tracks; albums still crashed on it. + m = AlbumMetadata.from_tidal(_tidal_album(copyright=None, releaseDate=None)) + assert m.copyright == "" + # No "Unkn" (the first four letters of "Unknown") in tags or folder names. + assert (m.year, m.date) == ("Unknown", None) + + +def test_tidal_album_folder_details_match_other_sources(): + m = AlbumMetadata.from_tidal(_tidal_album()) + assert (m.info.container, m.info.bit_depth, m.info.sampling_rate) == ( + "FLAC", + 16, + 44.1, + ) + assert m.albumartist == "A, B" + assert (m.tracktotal, m.disctotal) == (9, 2) + + +def test_tidal_hires_album_folder_shows_the_real_stream_format(): + # The album itself only says LOSSLESS; the client adds what the stream is. + m = AlbumMetadata.from_tidal( + _tidal_album(streamQuality={"bitDepth": 24, "sampleRate": 96000}) + ) + assert (m.info.quality, m.info.bit_depth, m.info.sampling_rate) == (3, 24, 96) + folder = m.format_folder_path( + "{title} [{container}] [{bit_depth}B-{sampling_rate}kHz]" + ) + assert folder == "Album [FLAC] [24B-96kHz]" + + +def test_tidal_hires_album_keeps_fractional_khz(): + m = AlbumMetadata.from_tidal( + _tidal_album(streamQuality={"bitDepth": 24, "sampleRate": 88200}) + ) + assert m.info.sampling_rate == 88.2 + + +def test_tidal_track_response_gives_its_album(): + track = { + "id": 99, + "allowStreaming": True, + "audioQuality": "HIGH", + "artists": [{"name": "A"}], + "streamStartDate": "2020-02-02T00:00:00.000+0000", + "volumeNumber": 1, + "copyright": None, + "album": {"id": 10, "title": "Album", "cover": "ab-cd"}, + } + m = AlbumMetadata.from_track_resp(track, "tidal") + assert (m.info.id, m.album, m.albumartist, m.year) == ("10", "Album", "A", "2020") + assert m.info.container == "AAC" + + +def test_deezer_track_without_album_tracklist(): + track = { + "explicit_lyrics": True, + "contributors": [ + {"name": "A", "type": "artist"}, + {"name": "Producer", "type": "producer"}, + ], + "album": { + "id": 5, + "title": "Album", + **{f"cover_{s}": "u" for s in ("xl", "big", "medium", "small")}, + }, + } + m = AlbumMetadata.from_track_resp(track, "deezer") + assert (m.info.id, m.info.container, m.albumartist) == ("5", "FLAC", "A") + assert m.info.explicit and m.year == "Unknown" + + +def test_tidal_track_metadata(): + album = AlbumMetadata.from_tidal(_tidal_album()) + t = TrackMetadata.from_tidal( + album, + { + "id": 7, + "title": "Song ", + "version": "Live", + "explicit": True, + "artists": [{"name": "A"}, {"name": "B"}], + "trackNumber": 3, + "volumeNumber": 2, + }, + ) + assert (t.info.id, t.info.explicit, t.title) == ("7", True, "Song (Live)") + assert (t.artist, t.artists) == ("A, B", ["A", "B"]) + assert (t.tracknumber, t.discnumber, t.lyrics) == (3, 2, "") + + +def test_deezer_track_metadata(): + resp = { + "id": 8, + "title": "Song", + "track_position": 4, + "disk_number": 1, + "contributors": [ + {"name": "A", "type": "artist"}, + {"name": "B", "type": "artist"}, + {"name": "Producer", "type": "producer"}, + ], + "artist": {"name": "A"}, + } + album = AlbumMetadata.from_incomplete_deezer_track_resp( + resp + | { + "album": { + "id": 5, + "title": "Album", + **{f"cover_{s}": "u" for s in ("xl", "big", "medium", "small")}, + } + } + ) + t = TrackMetadata.from_deezer(album, resp) + assert (t.info.id, t.info.explicit) == ("8", False) + assert (t.artist, t.artists, album.albumartist) == ("A, B", ["A", "B"], "A, B") + # Without contributors, the main artist. + del resp["contributors"] + assert TrackMetadata.from_deezer(album, resp).artists == ["A"] + + +def test_soundcloud_track_metadata(): + resp = { + "id": "123|_original_download", + "title": " Song", + "user": {"username": "someone", "avatar_url": "https://a/large.jpg"}, + "artwork_url": None, + "publisher_metadata": {"explicit": True, "isrc": "X"}, + } + t = TrackMetadata.from_soundcloud(AlbumMetadata.from_soundcloud(resp), resp) + assert (t.info.id, t.info.explicit, t.title, t.artist, t.isrc) == ( + "123|_original_download", + True, + "Song", + "someone", + "X", + ) + assert (t.tracknumber, t.discnumber) == (1, 1) diff --git a/tests/test_parse_url.py b/tests/test_parse_url.py index 9048cd64..d002766b 100644 --- a/tests/test_parse_url.py +++ b/tests/test_parse_url.py @@ -51,6 +51,61 @@ def test_tidal_track_url(self): self.assertEqual(groups[1], "track") # media_type self.assertEqual(groups[2], "3083287") # item_id + def test_tidal_share_url_with_u_suffix(self): + """Test that Tidal share links ending in /u parse to the real item id. + + The share sheet appends "/u", which would otherwise be taken as the + item id and 404 against the API. + """ + for url in ( + "https://tidal.com/album/152697662/u", + "https://tidal.com/album/152697662/u/", + "https://tidal.com/browse/album/152697662/u", + ): + with self.subTest(url=url): + result = parse_url(url) + + self.assertIsNotNone(result) + self.assertIsInstance(result, GenericURL) + self.assertEqual(result.source, "tidal") + + groups = result.match.groups() + self.assertEqual(groups[1], "album") # media_type + self.assertEqual(groups[2], "152697662") # item_id + + def test_deezer_share_links(self): + """Old Firebase share links and the current link.deezer.com ones.""" + for url in ( + "https://deezer.page.link/qfomS8twgYA35mgQ7", + "https://dzr.page.link/qfomS8twgYA35mgQ7", + "https://link.deezer.com/s/30hqmqELWXacckhxPdGqX", + ): + with self.subTest(url=url): + result = parse_url(url) + self.assertIsInstance(result, DeezerDynamicURL) + self.assertEqual(result.source, "deezer") + + def test_deezer_standard_link_regex(self): + """The id is read from the URL a share link redirects to.""" + regex = DeezerDynamicURL.standard_link_re + for url, expected in ( + ("https://www.deezer.com/en/track/4195713?host=0", ("track", "4195713")), + ("https://www.deezer.com/album/723513301", ("album", "723513301")), + ( + "https://www.deezer.com/pt-br/playlist/908622995", + ("playlist", "908622995"), + ), + ): + with self.subTest(url=url): + self.assertEqual(regex.search(url).groups(), expected) + + def test_deezer_loved_tracks_url(self): + from streamrip.rip.parse_url import DeezerFavoriteURL + + result = parse_url("https://www.deezer.com/fr/profile/1234567/loved") + self.assertIsInstance(result, DeezerFavoriteURL) + self.assertEqual(result.match.group(1), "1234567") + def test_deezer_track_url(self): """Test that Deezer track URLs are matched correctly.""" url = "https://www.deezer.com/track/4195713" diff --git a/tests/test_playlist.py b/tests/test_playlist.py new file mode 100644 index 00000000..f2e9237a --- /dev/null +++ b/tests/test_playlist.py @@ -0,0 +1,131 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from streamrip.config import Config +from streamrip.exceptions import NonStreamableError +from streamrip.media.playlist import ( + PendingLastfmPlaylist, + PendingPlaylistTrack, + _playlist_folder, +) + + +def _search_client(source, search): + client = MagicMock() + client.source = source + client.search = AsyncMock(side_effect=search) + return client + + +@pytest.mark.asyncio +async def test_lastfm_search_error_falls_back_instead_of_failing(): + main = _search_client("tidal", ConnectionError("boom")) + fallback = _search_client("deezer", [[{"data": [{"id": 7, "title": "Song"}]}]]) + playlist = PendingLastfmPlaylist("url", main, fallback, MagicMock(), MagicMock()) + status = PendingLastfmPlaylist.Status(0, 0, 1) + callback = MagicMock() + + assert await playlist._make_query("Song Artist", status, callback) == ("7", True) + assert (status.found, status.failed) == (1, 0) + callback.assert_called_once() + + +@pytest.mark.asyncio +async def test_lastfm_query_with_an_empty_page_counts_as_not_found(): + main = _search_client("deezer", [[{"data": []}]]) + playlist = PendingLastfmPlaylist("url", main, None, MagicMock(), MagicMock()) + status = PendingLastfmPlaylist.Status(0, 0, 1) + + assert await playlist._make_query("x", status, MagicMock()) == (None, False) + assert status.failed == 1 + + +def _playlist_track(get_metadata): + client = MagicMock() + client.source = "tidal" + client.get_metadata = get_metadata + db = MagicMock() + db.downloaded.return_value = False + track = PendingPlaylistTrack("42", client, MagicMock(), "/x", "Mix", 1, db) + return track, db + + +@pytest.mark.asyncio +async def test_playlist_track_that_cannot_be_fetched_is_kept_for_repair(): + track, db = _playlist_track(AsyncMock(side_effect=NonStreamableError("gone"))) + + assert await track.resolve() is None + db.set_failed.assert_called_once_with("tidal", "track", "42") + + +@pytest.mark.asyncio +async def test_playlist_track_with_unreadable_metadata_is_kept_for_repair(): + track, db = _playlist_track(AsyncMock(return_value={"unexpected": "shape"})) + + assert await track.resolve() is None + db.set_failed.assert_called_once_with("tidal", "track", "42") + + +def _deezer_track(position, disc): + return { + "id": 7, + "title": "Song", + "artist": {"name": "Artist"}, + "contributors": [{"name": "Artist", "type": "artist"}], + "track_position": position, + "disk_number": disc, + "album": { + "id": 70, + "title": "Their Own Album", + "release_date": "2020-01-01", + **{ + f"cover_{s}": "https://c/x.jpg" + for s in ("xl", "big", "medium", "small") + }, + }, + } + + +@pytest.mark.asyncio +async def test_playlist_track_is_tagged_as_part_of_one_album(monkeypatch): + monkeypatch.setattr( + "streamrip.media.playlist.download_artwork", + AsyncMock(return_value=(None, None)), + ) + client = MagicMock() + client.source = "deezer" + client.get_metadata = AsyncMock(return_value=_deezer_track(position=5, disc=2)) + client.get_downloadable = AsyncMock() + db = MagicMock() + db.downloaded.return_value = False + pending = PendingPlaylistTrack( + "7", client, Config.defaults(), "/x", "Road Trip", 3, db, total=50 + ) + + meta = (await pending.resolve()).meta + + assert (meta.tracknumber, meta.discnumber) == (3, 1) + album = meta.album + assert (album.tracktotal, album.disctotal) == (50, 1) + # One album artist for the whole playlist, or servers split it per artist. + assert (album.album, album.albumartist, album.compilation) == ( + "Road Trip", + "Various Artists", + "1", + ) + + +def test_tracks_uploaded_to_deezer_are_left_out_of_a_playlist(): + from streamrip.metadata import PlaylistMetadata + + resp = {"title": "Mix", "tracks": [{"id": 1}, {"id": -5}, {"id": "2"}]} + assert PlaylistMetadata.from_deezer(resp).ids() == ["1", "2"] + + +def test_playlist_folder_is_one_folder_and_follows_restrict_characters(): + config = Config.defaults() + config.session.downloads.folder = "/music" + assert _playlist_folder(config, "Café / Mix") == "/music/Café Mix" + config.session.filepaths.restrict_characters = True + assert _playlist_folder(config, "Café / Mix") == "/music/Caf Mix" diff --git a/tests/test_qobuz_download_only.py b/tests/test_qobuz_download_only.py new file mode 100644 index 00000000..9d015379 --- /dev/null +++ b/tests/test_qobuz_download_only.py @@ -0,0 +1,156 @@ +"""Unit tests for the conditional download-store (free/purchased account) path. + +A free Qobuz account (no streaming subscription) returns an empty +``credential.parameters`` on login. It cannot stream, but it can still download +albums it has *purchased*. These tests verify that: + +* a subscriber (non-empty parameters) is NOT flagged download-only and keeps + using ``intent=stream`` (no streaming regression), +* a free account (empty parameters) is flagged ``download_only`` without + raising, and +* ``_request_file_url`` selects the matching ``intent`` in BOTH the signed + request-signature preimage and the params dict. + +All network calls are mocked; these run without Qobuz credentials. +""" + +from unittest.mock import AsyncMock + +from util import arun + +from streamrip.client.qobuz import QobuzClient, file_url_signature +from streamrip.config import Config + + +class _FakeSession: + """Minimal stand-in for the aiohttp session used during login().""" + + def __init__(self): + self.headers = {} + + async def close(self): + pass + + +def _make_client() -> QobuzClient: + config = Config.defaults() + c = config.session.qobuz + c.email_or_userid = "13103092" + c.password_or_token = "fake-token" + c.use_auth_token = True + # Pre-seed app_id/secrets so login() skips the spoofer/network fetch. + c.app_id = "123456789" + c.secrets = ["fakesecret"] + return QobuzClient(config) + + +def _login_resp(parameters): + return { + "user": {"credential": {"parameters": parameters}}, + "user_auth_token": "fake-uat", + } + + +def _run_login(monkeypatch, parameters) -> QobuzClient: + client = _make_client() + monkeypatch.setattr( + "streamrip.client.qobuz.new_session", lambda **_: _FakeSession() + ) + monkeypatch.setattr( + client, + "_api_request", + AsyncMock(return_value=(200, _login_resp(parameters))), + ) + monkeypatch.setattr( + client, "_get_valid_secret", AsyncMock(return_value="fakesecret") + ) + arun(client.login()) + return client + + +def test_subscriber_login_not_download_only(monkeypatch): + """Non-empty credential.parameters -> NOT download_only, does not raise.""" + client = _run_login(monkeypatch, {"lossy_streaming": True, "hires_streaming": True}) + assert client.download_only is False + assert client.logged_in is True + + +def test_free_account_login_sets_download_only(monkeypatch): + """Empty credential.parameters -> download_only=True, does NOT raise.""" + client = _run_login(monkeypatch, []) + assert client.download_only is True + # Login still succeeds; the account can download purchased content. + assert client.logged_in is True + + +def _capture_file_url_params(monkeypatch, download_only: bool) -> dict: + client = _make_client() + client.download_only = download_only + captured: dict = {} + + async def fake_api(epoint, params): + captured["epoint"] = epoint + captured["params"] = params + return (200, {}) + + monkeypatch.setattr(client, "_api_request", fake_api) + arun(client._request_file_url("19512574", 3, "abc123secret")) + return captured + + +def test_request_file_url_download_intent_when_download_only(monkeypatch): + """download_only=True -> intent=download in BOTH params and signed preimage.""" + secret = "abc123secret" + track_id = "19512574" + quality = 3 + client = _make_client() + client.download_only = True + captured: dict = {} + + async def fake_api(epoint, params): + captured["params"] = params + return (200, {}) + + monkeypatch.setattr(client, "_api_request", fake_api) + arun(client._request_file_url(track_id, quality, secret)) + + params = captured["params"] + # 1. params dict uses intent=download + assert params["intent"] == "download" + # 2. the signed preimage used intent=download too (reconstruct + md5 match) + format_id = QobuzClient.get_quality(quality) + assert params["request_sig"] == file_url_signature( + format_id, "download", track_id, params["request_ts"], secret + ) + + +def test_request_file_url_stream_intent_when_subscriber(monkeypatch): + """download_only=False -> intent=stream in BOTH params and signed preimage.""" + secret = "abc123secret" + track_id = "19512574" + quality = 3 + client = _make_client() + client.download_only = False + captured: dict = {} + + async def fake_api(epoint, params): + captured["params"] = params + return (200, {}) + + monkeypatch.setattr(client, "_api_request", fake_api) + arun(client._request_file_url(track_id, quality, secret)) + + params = captured["params"] + assert params["intent"] == "stream" + format_id = QobuzClient.get_quality(quality) + assert params["request_sig"] == file_url_signature( + format_id, "stream", track_id, params["request_ts"], secret + ) + + +def test_file_url_signature_known_answer(): + """Pin the signature format Qobuz expects (known-answer test).""" + assert ( + file_url_signature(27, "stream", "19512574", 1700000000.5, "secret") + == "7e51324fbe3fb7a052f218264ed520f1" + ) diff --git a/tests/test_qobuz_errors.py b/tests/test_qobuz_errors.py new file mode 100644 index 00000000..5329fcd5 --- /dev/null +++ b/tests/test_qobuz_errors.py @@ -0,0 +1,124 @@ +"""Qobuz failures should explain themselves, and never quote credentials. + +`streamrip search qobuz ...` crashed with a bare `AssertionError: 400`. The 400 was +Qobuz's search backend failing transiently -- "Impossible to connect, please +check your Algolia Application Id." -- and the same search succeeded seconds +later. Separately, Qobuz takes credentials as URL query parameters, and both an +HTML error page and a failed login used to put them into error output. +""" + +import contextlib +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from streamrip.client.qobuz import QobuzClient +from streamrip.exceptions import APIError, AuthenticationError + +ALGOLIA = { + "message": "Impossible to connect, please check your Algolia Application Id." +} +TOKEN = "SECRET-TOKEN-abcdefghij" + + +@pytest.fixture(autouse=True) +def no_wait(): + with patch("streamrip.client.qobuz.asyncio.sleep", new=AsyncMock()): + yield + + +def _client(*responses): + c = QobuzClient.__new__(QobuzClient) + c._api_request = AsyncMock(side_effect=list(responses)) + return c + + +@pytest.mark.asyncio +async def test_transient_failure_is_retried_once(): + ok = {"artists": {"items": [], "total": 0}} + c = _client((400, ALGOLIA), (200, ok)) + assert await c._request_ok("artist/search", {}) == ok + assert c._api_request.await_count == 2 + + +@pytest.mark.asyncio +async def test_persistent_failure_raises_with_qobuz_message(): + c = _client((400, ALGOLIA), (400, ALGOLIA)) + with pytest.raises(APIError, match="Algolia"): + await c.search("artist", "Radioaktivists", limit=100) + assert c._api_request.await_count == 2 + + +@pytest.mark.asyncio +async def test_other_errors_are_not_retried(): + c = _client((400, {"message": "Invalid parameter"})) + with pytest.raises(APIError, match="Invalid parameter"): + await c._request_ok("artist/search", {}) + assert c._api_request.await_count == 1 + + +class _HtmlErrorPage: + status = 502 + content_type = "text/html" + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def json(self): + raise AssertionError("must not parse an HTML page as JSON") + + +@pytest.mark.asyncio +async def test_html_error_page_is_reported_by_status_without_the_url(): + c = QobuzClient.__new__(QobuzClient) + c.rate_limiter = contextlib.nullcontext() + c.session = MagicMock() + c.session.get = MagicMock(return_value=_HtmlErrorPage()) + status, page = await c._api_request( + "user/login", {"user_id": "123456789", "user_auth_token": TOKEN} + ) + assert status == 502 + assert "non-JSON" in page["message"] + assert TOKEN not in str(page) + + +@pytest.mark.asyncio +async def test_failed_login_does_not_quote_the_token(): + c = QobuzClient.__new__(QobuzClient) + c.logged_in = False + c.config = MagicMock() + q = c.config.session.qobuz + q.use_auth_token = True + q.email_or_userid = "123456789" + q.password_or_token = TOKEN + q.app_id = "987654321" + q.secrets = ["s1"] + c._api_request = AsyncMock(return_value=(401, {})) + session = MagicMock() + session.close = AsyncMock() + with patch("streamrip.client.qobuz.new_session", return_value=session): + with pytest.raises(AuthenticationError) as err: + await c.login() + assert TOKEN not in str(err.value) + assert "user_auth_token" in str(err.value) + # A failed login must not leave the aiohttp session open. + session.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_search_command_reports_failure_instead_of_crashing(): + from streamrip.rip.main import Main + + m = Main.__new__(Main) + client = MagicMock() + client.search = AsyncMock( + side_effect=APIError("Qobuz artist/search failed (HTTP 400): Algolia") + ) + m.get_logged_in_client = AsyncMock(return_value=client) + with patch("streamrip.rip.main.console") as console: + await m.search_interactive("qobuz", "artist", "Radioaktivists") + printed = " ".join(str(c.args[0]) for c in console.print.call_args_list) + assert "Search failed" in printed and "Algolia" in printed diff --git a/tests/test_qobuz_playlist.py b/tests/test_qobuz_playlist.py new file mode 100644 index 00000000..9f2f8e22 --- /dev/null +++ b/tests/test_qobuz_playlist.py @@ -0,0 +1,73 @@ +"""Qobuz playlist parsing and pagination. + +Qobuz caps the inline track list of playlist/get at one page (500 tracks) and, +since its July 2026 API change, often returns it empty. The complete list of +ids comes back in the ``track_ids`` extra instead. +""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from streamrip.client.qobuz import QobuzClient +from streamrip.metadata import PlaylistMetadata + +with open("tests/qobuz_track_resp.json") as f: + TRACK = json.load(f) + + +def test_track_ids_preferred_over_truncated_inline_list(): + resp = { + "name": "Long playlist", + "tracks": {"items": [TRACK], "total": 3}, + "track_ids": [1, 2, 3], + } + meta = PlaylistMetadata.from_resp(resp, "qobuz") + assert meta.ids() == ["1", "2", "3"] + + +def test_empty_inline_list_falls_back_to_track_ids(): + resp = {"name": "New API shape", "tracks": {"items": []}, "track_ids": [7, 8]} + assert PlaylistMetadata.from_resp(resp, "qobuz").ids() == ["7", "8"] + + +def test_inline_list_used_when_complete(): + resp = {"name": "Short", "tracks": {"items": [TRACK], "total": 1}} + meta = PlaylistMetadata.from_resp(resp, "qobuz") + assert meta.ids() == [str(TRACK["id"])] + + +def test_malformed_track_does_not_sink_playlist(): + resp = {"name": "Mixed", "tracks": {"items": [{"id": 1}, TRACK]}} + meta = PlaylistMetadata.from_resp(resp, "qobuz") + assert meta.ids() == [str(TRACK["id"])] + + +@pytest.mark.asyncio +async def test_long_playlist_without_track_ids_is_paginated(): + c = QobuzClient.__new__(QobuzClient) + c._request_ok = AsyncMock( + side_effect=[ + {"tracks": {"items": [{"id": i} for i in range(500, 1000)]}}, + {"tracks": {"items": [{"id": i} for i in range(1000, 1200)]}}, + ] + ) + resp = { + "tracks": {"items": [{"id": i} for i in range(500)], "total": 1200}, + } + await c._fetch_remaining_playlist_tracks( + "playlist/get", {"playlist_id": "x", "limit": 500, "offset": 0}, resp + ) + assert [t["id"] for t in resp["tracks"]["items"]] == list(range(1200)) + offsets = [call.args[1]["offset"] for call in c._request_ok.call_args_list] + assert offsets == [500, 1000] + + +@pytest.mark.asyncio +async def test_no_pagination_when_track_ids_present(): + c = QobuzClient.__new__(QobuzClient) + c._request_ok = AsyncMock() + resp = {"tracks": {"items": [{"id": 1}], "total": 900}, "track_ids": [1, 2]} + await c._fetch_remaining_playlist_tracks("playlist/get", {"limit": 500}, resp) + c._request_ok.assert_not_called() diff --git a/tests/test_qobuz_token_capture.py b/tests/test_qobuz_token_capture.py new file mode 100644 index 00000000..01836d2c --- /dev/null +++ b/tests/test_qobuz_token_capture.py @@ -0,0 +1,79 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from streamrip.rip.qobuz_token_capture import ( + QobuzTokenCaptureError, + _capture_qobuz_auth_token_async, + _install_chromium, +) + + +@pytest.mark.asyncio +async def test_install_chromium_succeeds(): + proc = AsyncMock() + proc.communicate.return_value = (b"", None) + proc.returncode = 0 + with patch("asyncio.create_subprocess_exec", return_value=proc): + await _install_chromium() # should not raise + + +@pytest.mark.asyncio +async def test_install_chromium_failure_raises(): + proc = AsyncMock() + proc.communicate.return_value = (b"network error", None) + proc.returncode = 1 + with patch("asyncio.create_subprocess_exec", return_value=proc): + with pytest.raises(QobuzTokenCaptureError, match="Could not download"): + await _install_chromium() + + +@pytest.mark.asyncio +async def test_missing_chromium_is_installed_automatically_then_retried(): + """The first launch fails the way Playwright does when the browser isn't + downloaded yet; streamrip should install it and retry, not just give up. + """ + browser = AsyncMock() + context = AsyncMock() + page = MagicMock() + page.on = MagicMock() + page.goto = AsyncMock() + page.wait_for_timeout = AsyncMock() + + browser.new_context.return_value = context + context.new_page.return_value = page + + chromium = MagicMock() + chromium.launch = AsyncMock( + side_effect=[ + Exception( + "BrowserType.launch: Executable doesn't exist ... " + "Run 'playwright install' to download new browsers." + ), + browser, + ] + ) + + playwright_ctx = MagicMock() + playwright_ctx.chromium = chromium + async_playwright_cm = AsyncMock() + async_playwright_cm.__aenter__.return_value = playwright_ctx + async_playwright_cm.__aexit__.return_value = False + + install_mock = AsyncMock() + + with ( + patch( + "playwright.async_api.async_playwright", + return_value=async_playwright_cm, + ), + patch("streamrip.rip.qobuz_token_capture._install_chromium", install_mock), + pytest.raises(QobuzTokenCaptureError, match="Could not detect"), + ): + # timeout_s=0: falls straight through to "no login detected" once + # the (successful, second-attempt) browser is up, without needing + # to simulate real Qobuz login network traffic. + await _capture_qobuz_auth_token_async(timeout_s=0) + + install_mock.assert_awaited_once() + assert chromium.launch.await_count == 2 diff --git a/tests/test_reauth_prompt.py b/tests/test_reauth_prompt.py new file mode 100644 index 00000000..90350ac2 --- /dev/null +++ b/tests/test_reauth_prompt.py @@ -0,0 +1,70 @@ +"""Stored credentials that stop working should offer a fresh login. + +`has_creds()` only checks that something is *stored*, not that it still works, +so an expired token passes that check and the failure surfaces from `login()`. +Before this was handled, that meant a traceback -- even though the prompter +that fixes it was already built. +""" + +from contextlib import ExitStack +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from streamrip.exceptions import AuthenticationError +from streamrip.rip.main import Main + + +def _main_with_expired_token(): + main = Main.__new__(Main) + client = MagicMock() + client.logged_in = False + client.login = AsyncMock( + side_effect=AuthenticationError("Tidal refresh token has expired.") + ) + main.clients = {"tidal": client} + main.config = MagicMock() + + prompter = MagicMock() + prompter.has_creds.return_value = True + prompter.prompt_and_login = AsyncMock( + side_effect=lambda: setattr(client, "logged_in", True) + ) + return main, client, prompter + + +def _patches(prompter, *, isatty: bool, confirm: bool): + stack = ExitStack() + stack.enter_context(patch("streamrip.rip.main.get_prompter", return_value=prompter)) + stack.enter_context(patch("sys.stdin.isatty", return_value=isatty)) + stack.enter_context(patch("streamrip.rip.main.Confirm.ask", return_value=confirm)) + return stack + + +@pytest.mark.asyncio +async def test_prompts_and_recovers_when_user_agrees(): + main, client, prompter = _main_with_expired_token() + with _patches(prompter, isatty=True, confirm=True): + result = await main.get_logged_in_client("tidal") + prompter.prompt_and_login.assert_awaited_once() + prompter.save.assert_called_once() + assert result is client + + +@pytest.mark.asyncio +async def test_propagates_when_user_declines(): + main, _, prompter = _main_with_expired_token() + with _patches(prompter, isatty=True, confirm=False): + with pytest.raises(AuthenticationError, match="refresh token has expired"): + await main.get_logged_in_client("tidal") + prompter.prompt_and_login.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_never_prompts_without_a_terminal(): + """streamrip runs from cron and from scripts, where a hidden y/n hangs forever.""" + main, _, prompter = _main_with_expired_token() + with _patches(prompter, isatty=False, confirm=True): + with pytest.raises(AuthenticationError, match="interactive terminal"): + await main.get_logged_in_client("tidal") + prompter.prompt_and_login.assert_not_awaited() diff --git a/tests/test_resumable_download.py b/tests/test_resumable_download.py new file mode 100644 index 00000000..f2439081 --- /dev/null +++ b/tests/test_resumable_download.py @@ -0,0 +1,187 @@ +"""Tests for resumable downloads in fast_async_download. + +These spin up a local HTTP server that interrupts the first response +mid-stream (reproducing the IncompleteRead errors seen on large tracks) +and verify that a subsequent attempt resumes and produces a byte-for-byte +correct file. +""" + +import hashlib +import http.server +import os +import threading + +import pytest + +from streamrip.client.downloadable import fast_async_download + + +def _payload(n: int) -> bytes: + return bytes((i * 31 + 7) % 256 for i in range(n)) + + +def _start_server(handler_cls): + srv = http.server.HTTPServer(("127.0.0.1", 0), handler_cls) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, port + + +@pytest.mark.asyncio +async def test_resume_with_range_support(tmp_path): + """Server drops the connection mid-stream, then honors Range with 206.""" + total = 1_000_000 + payload = _payload(total) + full_sha = hashlib.sha256(payload).hexdigest() + request_ranges = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + rng = self.headers.get("Range") + request_ranges.append(rng) + if rng is None: + self.send_response(200) + self.send_header("Content-Length", str(total)) + self.end_headers() + self.wfile.write(payload[: total // 2]) + self.wfile.flush() + self.close_connection = True + self.connection.close() + else: + start = int(rng.replace("bytes=", "").split("-")[0]) + self.send_response(206) + self.send_header("Content-Range", f"bytes {start}-{total - 1}/{total}") + self.send_header("Content-Length", str(total - start)) + self.end_headers() + self.wfile.write(payload[start:]) + + srv, port = _start_server(Handler) + url = f"http://127.0.0.1:{port}/track.flac" + path = str(tmp_path / "track.flac") + + last_exc = None + for attempt in range(4): + try: + await fast_async_download(path, url, {}, lambda n: None, resume=attempt > 0) + last_exc = None + break + except Exception as e: + last_exc = e + + srv.shutdown() + + assert last_exc is None + assert os.path.getsize(path) == total + with open(path, "rb") as f: + assert hashlib.sha256(f.read()).hexdigest() == full_sha + # The retry sent a Range header (resume actually happened). + assert any(r and r.startswith("bytes=") for r in request_ranges) + + +@pytest.mark.asyncio +async def test_resume_falls_back_when_range_ignored(tmp_path): + """If the server ignores Range and replies 200 with the full body, + the partial file must be overwritten, not appended to.""" + total = 600_000 + payload = _payload(total) + full_sha = hashlib.sha256(payload).hexdigest() + calls = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + calls.append(self.headers.get("Range")) + if len(calls) == 1: + self.send_response(200) + self.send_header("Content-Length", str(total)) + self.end_headers() + self.wfile.write(payload[: total // 2]) + self.wfile.flush() + self.close_connection = True + self.connection.close() + else: + # Range ignored: send the whole file from the start. + self.send_response(200) + self.send_header("Content-Length", str(total)) + self.end_headers() + self.wfile.write(payload) + + srv, port = _start_server(Handler) + url = f"http://127.0.0.1:{port}/x.flac" + path = str(tmp_path / "x.flac") + + for attempt in range(4): + try: + await fast_async_download(path, url, {}, lambda n: None, resume=attempt > 0) + break + except Exception: + pass + + srv.shutdown() + + # No duplicated bytes despite the partial file already existing. + assert os.path.getsize(path) == total + with open(path, "rb") as f: + assert hashlib.sha256(f.read()).hexdigest() == full_sha + + +@pytest.mark.asyncio +async def test_existing_file_is_overwritten_without_resume(tmp_path): + """A first attempt must never append to an unrelated existing file. + + Covers re-downloading over a tagged track or a downscaled cover.jpg, which + would otherwise be "resumed" into a corrupt file. + """ + payload = _payload(50_000) + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + assert self.headers.get("Range") is None + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + srv, port = _start_server(Handler) + path = tmp_path / "cover.jpg" + path.write_bytes(b"old, smaller file") + await fast_async_download( + str(path), f"http://127.0.0.1:{port}/c.jpg", {}, lambda n: None + ) + srv.shutdown() + assert path.read_bytes() == payload + + +@pytest.mark.asyncio +async def test_multidict_headers_are_accepted(tmp_path): + """aiohttp's istr header keys used to make requests refuse the call (#941).""" + from multidict import CIMultiDict + + seen = {} + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + seen["ua"] = self.headers.get("User-Agent") + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + srv, port = _start_server(Handler) + headers = CIMultiDict({"User-Agent": "streamrip-test"}) + await fast_async_download( + str(tmp_path / "f"), f"http://127.0.0.1:{port}/", headers, lambda n: None + ) + srv.shutdown() + assert seen["ua"] == "streamrip-test" diff --git a/tests/test_search_results.py b/tests/test_search_results.py new file mode 100644 index 00000000..91e8f8a4 --- /dev/null +++ b/tests/test_search_results.py @@ -0,0 +1,16 @@ +from streamrip.metadata.search_results import ArtistSummary + + +def test_preview_omits_album_count_when_source_does_not_provide_it(): + # Tidal's artist search response has no album-count field at all, so + # this used to always show a made-up "Unknown Albums". + summary = ArtistSummary.from_item({"id": 123, "name": "The Kid LAROI"}) + assert summary.num_albums is None + assert summary.preview() == "ID: 123" + + +def test_preview_shows_album_count_when_source_provides_it(): + summary = ArtistSummary.from_item( + {"id": 456, "name": "Fleetwood Mac", "albums_count": 17} + ) + assert summary.preview() == "17 Albums\n\nID: 456" diff --git a/tests/test_soundcloud.py b/tests/test_soundcloud.py new file mode 100644 index 00000000..2ba9e340 --- /dev/null +++ b/tests/test_soundcloud.py @@ -0,0 +1,162 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from streamrip.client.soundcloud import SoundcloudClient, _find_client_id +from streamrip.config import Config + +CLIENT_ID = "a" * 16 + "B" * 8 + "1234567z" + + +def _track(*transcodings, **extra): + return { + "id": 1, + "streamable": True, + "policy": "ALLOW", + "downloadable": False, + "has_downloads_left": False, + "media": {"transcodings": list(transcodings)}, + **extra, + } + + +def _tc(protocol, mime, url): + return {"url": url, "format": {"protocol": protocol, "mime_type": mime}} + + +def test_client_id_found_in_bundle(): + assert _find_client_id(f'...,client_id:"{CLIENT_ID}",env:"production"') == CLIENT_ID + assert _find_client_id(f"fetch('/x?client_id={CLIENT_ID}&a=1')") == CLIENT_ID + assert _find_client_id("nothing to see here") is None + + +def test_progressive_mp3_preferred(): + track = _track( + _tc("hls", "audio/mpeg", "https://x/stream/hls"), + _tc("progressive", "audio/mpeg", "https://x/stream/progressive"), + ) + assert SoundcloudClient._get_custom_id(track) == "1|https://x/stream/progressive" + + +def test_hls_mp3_used_when_no_progressive(): + track = _track( + _tc("hls", 'audio/mp4; codecs="mp4a.40.2"', "https://x/aac"), + _tc("hls", "audio/mpeg", "https://x/stream/hls"), + ) + assert SoundcloudClient._get_custom_id(track) == "1|https://x/stream/hls" + + +def test_no_mp3_transcoding_is_non_streamable_instead_of_assertion(): + track = _track(_tc("hls", 'audio/ogg; codecs="opus"', "https://x/opus")) + assert SoundcloudClient._get_custom_id(track).endswith("_non_streamable") + + +@pytest.mark.asyncio +async def test_refresh_tokens_scans_scripts_from_the_end(): + client = SoundcloudClient(Config.defaults()) + pages = { + "https://soundcloud.com/": ( + '' + '' + '' + ), + "https://a-v2.sndcdn.com/assets/49-b.js": f'client_id:"{CLIENT_ID}"', + "https://a-v2.sndcdn.com/assets/0-a.js": "no id here", + } + + def get(url): + resp = MagicMock() + resp.text = AsyncMock(return_value=pages[url]) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=resp) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + client.session = MagicMock() + client.session.get = MagicMock(side_effect=get) + assert await client._refresh_tokens() == (CLIENT_ID, "1700000000") + + +M3U = ( + "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" + + "".join(f"#EXTINF:10.0,\nhttps://seg/{n}.mp3\n" for n in range(3)) + + "#EXT-X-ENDLIST\n" +) + + +class _Resp: + def __init__(self, body: bytes = b"", fail: bool = False): + self.body, self.fail = body, fail + self.content = self + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + def raise_for_status(self): + if self.fail: + raise ConnectionError("segment failed") + + async def text(self, _encoding): + return self.body.decode() + + async def read(self): + return self.body + + +class _Session: + def __init__(self, failing=()): + self.failing = failing + + def get(self, url): + if url == "playlist": + return _Resp(M3U.encode()) + n = url.rsplit("/", 1)[1].split(".")[0] + return _Resp(n.encode(), fail=url in self.failing) + + +async def _fake_concat(paths, out, _ext): + with open(out, "wb") as f: + for p in paths: + with open(p, "rb") as segment: + f.write(segment.read()) + + +@pytest.fixture +def hls(tmp_path, monkeypatch): + from streamrip.client.downloadable import SoundcloudDownloadable + + temp = tmp_path / "temp" + temp.mkdir() + monkeypatch.setattr("tempfile.tempdir", str(temp)) + monkeypatch.setattr( + "streamrip.client.downloadable.concat_audio_files", _fake_concat + ) + + def make(failing=()): + info = {"type": "mp3", "url": "playlist"} + return SoundcloudDownloadable(_Session(failing), info) + + return make, temp, tmp_path / "out.mp3" + + +@pytest.mark.asyncio +async def test_hls_segments_are_joined_in_order_and_cleaned_up(hls): + make, temp, out = hls + + await make()._download(str(out), lambda _: None) + + assert out.read_bytes() == b"012" + assert list(temp.iterdir()) == [] + + +@pytest.mark.asyncio +async def test_hls_segments_are_cleaned_up_when_one_fails(hls): + make, temp, out = hls + + with pytest.raises(ConnectionError): + await make(failing={"https://seg/1.mp3"})._download(str(out), lambda _: None) + + assert list(temp.iterdir()) == [] diff --git a/tests/test_ssl_verification.py b/tests/test_ssl_verification.py index 85ccfb6e..eaf254b6 100644 --- a/tests/test_ssl_verification.py +++ b/tests/test_ssl_verification.py @@ -1,341 +1,98 @@ -import inspect import ssl from unittest.mock import AsyncMock, MagicMock, patch +import aiohttp import pytest -from streamrip.client.client import Client +from streamrip.client import new_session from streamrip.client.qobuz import QobuzSpoofer -from streamrip.rip.cli import latest_streamrip_version, rip -from streamrip.utils.ssl_utils import ( - create_ssl_context, - get_aiohttp_connector_kwargs, - print_ssl_error_help, -) +from streamrip.media.playlist import PendingLastfmPlaylist +from streamrip.rip.cli import coro, latest_streamrip_version, rip +from streamrip.utils import ssl_utils +from streamrip.utils.ssl_utils import get_aiohttp_connector_kwargs, print_ssl_error_help -@pytest.fixture -def mock_client_session(): - """Fixture that provides a mocked aiohttp.ClientSession.""" - with patch("aiohttp.ClientSession") as mock_session: - mock_session.return_value = AsyncMock() - yield mock_session +def test_no_verification(): + assert get_aiohttp_connector_kwargs(verify_ssl=False) == {"ssl": False} -@pytest.fixture -def mock_tcp_connector(): - """Fixture that provides a mocked aiohttp.TCPConnector.""" - with patch("aiohttp.TCPConnector") as mock_connector: - mock_connector.return_value = MagicMock() - yield mock_connector +def test_verification_uses_certifi_when_installed(monkeypatch): + certifi = MagicMock() + certifi.where.return_value = "/ca.pem" + monkeypatch.setattr(ssl_utils, "certifi", certifi) + with patch("ssl.create_default_context") as create: + assert get_aiohttp_connector_kwargs() == {"ssl": create.return_value} + create.assert_called_once_with(cafile="/ca.pem") -@pytest.fixture -def mock_ssl_context(): - """Fixture that provides a mocked SSL context.""" - with patch("ssl.create_default_context") as mock_ctx: - mock_ctx.return_value = MagicMock() - yield mock_ctx - - -@pytest.fixture -def mock_certifi(): - """Fixture that provides a mocked certifi module.""" - with patch("streamrip.utils.ssl_utils.HAS_CERTIFI", True): - with patch("streamrip.utils.ssl_utils.certifi") as mock_cert: - mock_cert.where.return_value = "/path/to/mock/cacert.pem" - yield mock_cert - - -def test_create_ssl_context_with_verification(mock_ssl_context): - """Test that create_ssl_context creates a proper SSL context with verification enabled.""" - # Call the function with verification enabled - ctx = create_ssl_context(verify=True) - - # Verify create_default_context was called - mock_ssl_context.assert_called_once() - - # Function should return the mocked context - assert ctx == mock_ssl_context.return_value - - -def test_create_ssl_context_without_verification(mock_ssl_context): - """Test that create_ssl_context disables verification when requested.""" - # Call the function with verification disabled - ctx = create_ssl_context(verify=False) - - # Verify create_default_context was called - mock_ssl_context.assert_called_once() - - # Check that verification was disabled on the context - assert ctx.check_hostname is False - assert ctx.verify_mode == ssl.CERT_NONE - - -def test_create_ssl_context_with_certifi(mock_ssl_context, mock_certifi): - """Test that create_ssl_context uses certifi when available.""" - # Call the function - create_ssl_context(verify=True) - - # Verify certifi.where was called - mock_certifi.where.assert_called_once() - - # Verify create_default_context was called with the certifi path - mock_ssl_context.assert_called_once_with(cafile=mock_certifi.where.return_value) - - -def test_get_aiohttp_connector_kwargs_with_verification(mock_ssl_context, mock_certifi): - """Test get_aiohttp_connector_kwargs with verification enabled with certifi.""" - # Mock the create_ssl_context function to control its return value - with patch("streamrip.utils.ssl_utils.create_ssl_context") as mock_create_ctx: - mock_ssl_ctx = MagicMock() - mock_create_ctx.return_value = mock_ssl_ctx - - # Call the function with verification enabled - kwargs = get_aiohttp_connector_kwargs(verify_ssl=True) - - # When certifi is available, it should return kwargs with ssl context - assert "ssl" in kwargs - assert kwargs["ssl"] == mock_ssl_ctx - - -def test_get_aiohttp_connector_kwargs_without_verification(): - """Test get_aiohttp_connector_kwargs with verification disabled.""" - # Call the function with verification disabled - kwargs = get_aiohttp_connector_kwargs(verify_ssl=False) - - # It should return kwargs with verify_ssl=False - assert kwargs == {"verify_ssl": False} - - -def test_client_get_session_supports_verify_ssl(): - """Test that Client.get_session supports verify_ssl parameter.""" - # Check if the get_session method accepts the verify_ssl parameter - signature = inspect.signature(Client.get_session) - - # Check for verify_ssl parameter - has_verify_ssl = "verify_ssl" in signature.parameters - - # Skip rather than fail if option isn't implemented yet - if not has_verify_ssl: - pytest.skip("verify_ssl parameter not implemented in Client.get_session yet") - - -@pytest.mark.asyncio -async def test_client_get_session_creates_connector(): - """Test that Client.get_session creates a session with correct parameters.""" - # Check if the get_session method accepts the verify_ssl parameter - signature = inspect.signature(Client.get_session) - - # Skip if verify_ssl is not in parameters - if "verify_ssl" not in signature.parameters: - pytest.skip("verify_ssl parameter not implemented in Client.get_session yet") - - # Patch the get_aiohttp_connector_kwargs function and the client session - with ( - patch( - "streamrip.client.client.get_aiohttp_connector_kwargs" - ) as mock_get_kwargs, - patch("aiohttp.ClientSession") as mock_client_session, - patch("aiohttp.TCPConnector") as mock_connector, - ): - mock_get_kwargs.return_value = {"verify_ssl": False} - mock_connector.return_value = MagicMock() - mock_client_session.return_value = AsyncMock() - - # Test with SSL verification disabled - await Client.get_session(verify_ssl=False) - - # Verify get_aiohttp_connector_kwargs was called with verify_ssl=False - mock_get_kwargs.assert_called_once_with(verify_ssl=False) - - -def test_latest_streamrip_version_supports_verify_ssl(): - """Test that latest_streamrip_version supports verify_ssl parameter.""" - # Check if the function accepts the verify_ssl parameter - signature = inspect.signature(latest_streamrip_version) - - # Check for verify_ssl parameter - has_verify_ssl = "verify_ssl" in signature.parameters - - # Skip rather than fail if option isn't implemented yet - if not has_verify_ssl: - pytest.skip( - "verify_ssl parameter not implemented in latest_streamrip_version yet" - ) +def test_verification_without_certifi_uses_system_certificates(monkeypatch): + monkeypatch.setattr(ssl_utils, "certifi", None) + assert get_aiohttp_connector_kwargs() == {"ssl": True} @pytest.mark.asyncio -async def test_latest_streamrip_version_creates_session(): - """Test that latest_streamrip_version creates a session with verify_ssl parameter.""" - # Check if the function accepts the verify_ssl parameter - signature = inspect.signature(latest_streamrip_version) - - # Skip if verify_ssl is not in parameters - if "verify_ssl" not in signature.parameters: - pytest.skip( - "verify_ssl parameter not implemented in latest_streamrip_version yet" - ) - - # Patch the get_aiohttp_connector_kwargs function and related modules - with ( - patch("streamrip.rip.cli.get_aiohttp_connector_kwargs") as mock_get_kwargs, - patch("aiohttp.ClientSession") as mock_client_session, - patch("aiohttp.TCPConnector") as mock_connector, - ): - mock_get_kwargs.return_value = {"verify_ssl": False} - mock_connector.return_value = MagicMock() - - # Setup mock responses for API calls - mock_session_instance = AsyncMock() - mock_client_session.return_value = mock_session_instance - - mock_context_manager = AsyncMock() - mock_session_instance.get.return_value = mock_context_manager - mock_context_manager.__aenter__.return_value.json.return_value = { - "info": {"version": "1.0.0"} - } - - # Make sure the test doesn't actually wait - with patch("streamrip.rip.cli.__version__", "1.0.0"): - # Run with SSL verification parameter - try: - await latest_streamrip_version(verify_ssl=False) - except Exception: - # We just need to ensure it doesn't raise TypeError for the verify_ssl parameter - pass - - # Verify get_aiohttp_connector_kwargs was called with verify_ssl=False - mock_get_kwargs.assert_called_once_with(verify_ssl=False) +async def test_new_session_follows_verify_ssl(): + with patch( + "streamrip.client.client.get_aiohttp_connector_kwargs", + return_value={"ssl": False}, + ) as kwargs: + async with new_session(verify_ssl=False): + pass + kwargs.assert_called_once_with(verify_ssl=False) @pytest.mark.asyncio -async def test_qobuz_spoofer_initialization(mock_client_session): - """Test that QobuzSpoofer initialization works with available parameters.""" - # Check if QobuzSpoofer accepts verify_ssl parameter - signature = inspect.signature(QobuzSpoofer.__init__) - has_verify_ssl = "verify_ssl" in signature.parameters - - # Create instance based on available parameters - if has_verify_ssl: - # Patch the get_aiohttp_connector_kwargs function for the __aenter__ method - with patch( - "streamrip.utils.ssl_utils.get_aiohttp_connector_kwargs" - ) as mock_get_kwargs: - mock_get_kwargs.return_value = {"verify_ssl": True} - - spoofer = QobuzSpoofer(verify_ssl=True) - assert spoofer is not None - - # Test __aenter__ and __aexit__ - with patch.object(spoofer, "session", None): - await spoofer.__aenter__() - - # Verify get_aiohttp_connector_kwargs was called - mock_get_kwargs.assert_called_once_with(verify_ssl=True) - - # Verify ClientSession was called - assert mock_client_session.called - - await spoofer.__aexit__(None, None, None) - else: - spoofer = QobuzSpoofer() - assert spoofer is not None - - with patch.object(spoofer, "session", None): - await spoofer.__aenter__() - assert mock_client_session.called - await spoofer.__aexit__(None, None, None) +async def test_qobuz_spoofer_follows_verify_ssl(): + # It took verify_ssl and then verified anyway. + with patch("streamrip.client.qobuz.new_session", return_value=AsyncMock()) as make: + async with QobuzSpoofer(verify_ssl=False): + pass + make.assert_called_once_with(verify_ssl=False) @pytest.mark.asyncio -async def test_lastfm_playlist_session_creation(mock_client_session): - """Test that PendingLastfmPlaylist creates a ClientSession.""" - from streamrip.media.playlist import PendingLastfmPlaylist - - # Mock objects needed for playlist - mock_client = MagicMock() - mock_fallback_client = MagicMock() - mock_config = MagicMock() - mock_db = MagicMock() - - # Create instance - pending_playlist = PendingLastfmPlaylist( - "https://www.last.fm/test", - mock_client, - mock_fallback_client, - mock_config, - mock_db, - ) - - # Check if our code expects verify_ssl in config - try: - mock_config.session.downloads.verify_ssl = False - with patch( - "streamrip.utils.ssl_utils.get_aiohttp_connector_kwargs" - ) as mock_get_kwargs: - mock_get_kwargs.return_value = {"verify_ssl": False} - - # Try to parse the playlist - with pytest.raises(Exception): - await pending_playlist._parse_lastfm_playlist() - except (AttributeError, TypeError): - pytest.skip( - "verify_ssl not used in PendingLastfmPlaylist._parse_lastfm_playlist yet" - ) +async def test_lastfm_session_follows_verify_ssl(): + config = MagicMock() + config.session.downloads.verify_ssl = False + playlist = PendingLastfmPlaylist("url", MagicMock(), None, config, MagicMock()) + with patch( + "streamrip.media.playlist.new_session", side_effect=RuntimeError("stop") + ) as make: + with pytest.raises(RuntimeError): + await playlist._parse_lastfm_playlist("https://www.last.fm/x") + make.assert_called_once_with(verify_ssl=False) @pytest.mark.asyncio -async def test_client_uses_config_settings(): - """Test that clients use SSL verification settings from config.""" - from streamrip.client.tidal import TidalClient - - # Mock the config - with patch("streamrip.config.Config") as mock_config: - mock_config = MagicMock() - mock_config.return_value = mock_config +async def test_update_check_follows_verify_ssl_and_is_never_fatal(): + with patch( + "streamrip.rip.cli.new_session", side_effect=RuntimeError("offline") + ) as make: + _, notes = await latest_streamrip_version(verify_ssl=False) + assert notes is None + assert make.call_args.kwargs["verify_ssl"] is False - # Set verify_ssl in config - mock_config.session.downloads.verify_ssl = False - # Create client - try: - client = TidalClient(mock_config) - - # Mock the session creation method - with patch.object(client, "get_session", AsyncMock()) as mock_get_session: - await client.login() +def test_cli_option_registered(): + assert any(getattr(p, "name", "") == "no_ssl_verify" for p in rip.params) - # Check that get_session was called with verify_ssl=False - mock_get_session.assert_called_once() - try: - # Try to access the call args to check for verify_ssl - call_kwargs = mock_get_session.call_args.kwargs - assert "verify_ssl" in call_kwargs - assert call_kwargs["verify_ssl"] is False - except (AttributeError, AssertionError): - pytest.skip("verify_ssl not used in TidalClient.login yet") - except Exception as e: - pytest.skip(f"Could not test TidalClient: {e}") +def test_certificate_error_in_any_command_prints_help(): + # Only `url` and `file` used to catch it; the others crashed. + error = aiohttp.ClientConnectorCertificateError( + MagicMock(), ssl.SSLCertVerificationError("self-signed certificate") + ) -def test_cli_option_registered(): - """Test that the --no-ssl-verify CLI option is registered.""" - # Check if the option exists in the command parameters - has_no_ssl_verify = False - for param in rip.params: - if getattr(param, "name", "") == "no_ssl_verify": - has_no_ssl_verify = True - break + @coro + async def command(): + raise error - assert has_no_ssl_verify, "CLI command should accept --no-ssl-verify option" + with patch("streamrip.rip.cli.print_ssl_error_help") as help: + command() + help.assert_called_once() -def test_error_handling_with_ssl_errors(): - """Test the error handling output with SSL errors.""" - with patch("sys.stdout"), patch("sys.exit") as mock_exit: - # Call the function +def test_help_exits_with_an_error(): + with patch("sys.stdout"), patch("sys.exit") as exit: print_ssl_error_help() - - # Check exit code - mock_exit.assert_called_once_with(1) + exit.assert_called_once_with(1) diff --git a/tests/test_tagger.py b/tests/test_tagger.py index 1bea4810..bbb2ab15 100644 --- a/tests/test_tagger.py +++ b/tests/test_tagger.py @@ -3,6 +3,8 @@ import pytest from mutagen.flac import FLAC +from mutagen.id3 import ID3 +from mutagen.mp4 import MP4 from util import arun from streamrip.metadata import ( @@ -16,6 +18,7 @@ TEST_FLAC_ORIGINAL = "tests/silence.flac" TEST_FLAC_COPY = "tests/silence_copy.flac" +TEST_M4A = "tests/silence.m4a" test_cover = "tests/1x1_pixel.jpg" @@ -29,14 +32,7 @@ def wipe_test_flac(): @pytest.fixture() def sample_metadata() -> TrackMetadata: return TrackMetadata( - TrackInfo( - id="12345", - quality=3, - bit_depth=24, - explicit=True, - sampling_rate=96, - work=None, - ), + TrackInfo(id="12345", explicit=True), "testtitle", AlbumMetadata( AlbumInfo("5678", 4, "flac"), @@ -45,23 +41,18 @@ def sample_metadata() -> TrackMetadata: "1999", ["rock", "pop"], Covers(), - 14, - 3, - "testalbumcomposer", - "testcomment", - compilation="testcompilation", + tracktotal=14, + disctotal=3, + albumcomposer="testalbumcomposer", copyright="(c) stuff (p) other stuff", date="1998-02-13", description="testdesc", - encoder="ffmpeg", - grouping="testgroup", - lyrics="ye ye ye", - purchase_date=None, ), "testartist", 3, 1, "testcomposer", + isrc="USABC1234567", ) @@ -73,7 +64,6 @@ def test_tag_flac_no_cover(sample_metadata): assert file["title"][0] == "testtitle" assert file["album"][0] == "testalbum" assert file["composer"][0] == "testcomposer" - assert file["comment"][0] == "testcomment" assert file["artist"][0] == "testartist" assert file["albumartist"][0] == "testalbumartist" assert file["year"][0] == "1999" @@ -83,7 +73,10 @@ def test_tag_flac_no_cover(sample_metadata): assert file["copyright"][0] == "© stuff ℗ other stuff" assert file["tracktotal"][0] == "14" assert file["date"][0] == "1998-02-13" - assert "purchase_date" not in file, file["purchase_date"] + assert file["description"][0] == "testdesc" + assert file["isrc"][0] == "USABC1234567" + # Empty lyrics (the default) are left out, not written as "". + assert "lyrics" not in file os.remove(TEST_FLAC_COPY) @@ -93,19 +86,76 @@ def test_tag_flac_cover(sample_metadata): arun(tag_file(TEST_FLAC_COPY, sample_metadata, test_cover)) file = FLAC(TEST_FLAC_COPY) assert file["title"][0] == "testtitle" - assert file["album"][0] == "testalbum" - assert file["composer"][0] == "testcomposer" - assert file["comment"][0] == "testcomment" - assert file["artist"][0] == "testartist" - assert file["albumartist"][0] == "testalbumartist" - assert file["year"][0] == "1999" - assert file["genre"][0] == "rock, pop" - assert file["tracknumber"][0] == "03" - assert file["discnumber"][0] == "01" - assert file["copyright"][0] == "© stuff ℗ other stuff" - assert file["tracktotal"][0] == "14" - assert file["date"][0] == "1998-02-13" with open(test_cover, "rb") as img: assert file.pictures[0].data == img.read() - assert "purchase_date" not in file, file["purchase_date"] os.remove(TEST_FLAC_COPY) + + +def test_tag_flac_multiple_artists(sample_metadata): + # Tracks with several credited artists (Tidal, Deezer) must land as + # separate ARTIST fields, not one "A, B" string a player has to + # re-split on its own. + sample_metadata.artists = ["The Kid LAROI", "Lil Mosey"] + shutil.copy(TEST_FLAC_ORIGINAL, TEST_FLAC_COPY) + wipe_test_flac() + arun(tag_file(TEST_FLAC_COPY, sample_metadata, None)) + file = FLAC(TEST_FLAC_COPY) + assert list(file["artist"]) == ["The Kid LAROI", "Lil Mosey"] + os.remove(TEST_FLAC_COPY) + + +def test_tag_m4a(sample_metadata, tmp_path): + path = str(tmp_path / "track.m4a") + shutil.copy(TEST_M4A, path) + sample_metadata.artists = ["A", "B"] + arun(tag_file(path, sample_metadata, test_cover)) + tags = MP4(path).tags + assert tags["\xa9nam"] == ["testtitle"] + assert tags["\xa9ART"] == ["A", "B"] + assert tags["aART"] == ["testalbumartist"] + # The composer used to be mapped onto the year's atom and lost. + assert tags["\xa9wrt"] == ["testcomposer"] + assert tags["\xa9day"] == ["1999"] + assert tags["trkn"] == [(3, 14)] + assert tags["disk"] == [(1, 3)] + assert tags["cprt"] == ["© stuff ℗ other stuff"] + assert tags["desc"] == ["testdesc"] + assert bytes(tags["----:com.apple.iTunes:ISRC"][0]) == b"USABC1234567" + assert "cpil" not in tags + assert "\xa9lyr" not in tags + assert len(tags["covr"]) == 1 + + +def test_tag_mp3(sample_metadata, tmp_path): + path = str(tmp_path / "track.mp3") + open(path, "wb").close() # an ID3 tag doesn't need an audio stream + sample_metadata.artists = ["A", "B"] + sample_metadata.album.compilation = "1" + arun(tag_file(path, sample_metadata, None)) + tags = ID3(path, translate=False) # as written, not upgraded to v2.4 + assert tags.version[:2] == (2, 3) + assert tags["TIT2"].text == ["testtitle"] + # ID3v2.3 has no multi-value text frames; "/" is its separator. + assert tags["TPE1"].text == ["A/B"] + assert tags["TCOM"].text == ["testcomposer"] + assert tags["TYER"].text == ["1999"] + assert tags["TRCK"].text == ["3/14"] + assert tags["TPOS"].text == ["1/3"] + assert tags["TSRC"].text == ["USABC1234567"] + assert tags["TCMP"].text == ["1"] + # The album description used to land in TIT1 (grouping), and empty + # lyrics in an empty USLT frame. + assert "TIT1" not in tags + assert not tags.getall("USLT") + + +def test_exclude_only_drops_the_named_tag(sample_metadata, tmp_path): + # Excluding the composer used to drop the year from M4A files too, as + # both were mapped onto the same atom. + path = str(tmp_path / "track.m4a") + shutil.copy(TEST_M4A, path) + arun(tag_file(path, sample_metadata, test_cover, exclude=["composer", "cover"])) + tags = MP4(path).tags + assert "\xa9wrt" not in tags + assert "covr" not in tags + assert tags["\xa9day"] == ["1999"] diff --git a/tests/test_tidal.py b/tests/test_tidal.py new file mode 100644 index 00000000..701da086 --- /dev/null +++ b/tests/test_tidal.py @@ -0,0 +1,564 @@ +import base64 +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from streamrip.client.downloadable import TidalDASHDownloadable, TidalDownloadable +from streamrip.client.tidal import ( + DEFAULT_CLIENT_ID, + HIRES_CLIENT_ID, + TidalClient, +) +from streamrip.config import Config +from streamrip.exceptions import MissingCredentialsError, NonStreamableError +from streamrip.metadata.util import tidal_quality_id +from streamrip.rip.prompter import TidalPrompter + +MPD = """ + + + + + + + + + + + + + + +""" + + +def _client(hires: bool = False) -> TidalClient: + """One lane (the lossless client) unless `hires`, like a config with + hires_client = false.""" + cfg = Config.defaults() + cfg.session.tidal.hires_client = hires + return TidalClient(cfg) + + +def test_default_quality_is_the_highest(): + tidal = Config.defaults().session.tidal + assert tidal.quality == 3 + assert tidal.hires_client is True + + +def test_defaults_ask_the_hires_client_first_and_the_lossless_one_second(): + client = TidalClient(Config.defaults()) + assert client.client_id == DEFAULT_CLIENT_ID + assert client.hires_lane.client_id == HIRES_CLIENT_ID + assert client.lanes() == [client, client.hires_lane] + + +def test_no_hires_lane_without_hires_client(): + client = _client() + assert client.hires_lane is None + assert client.lanes() == [client] + + +def test_no_hires_login_when_hires_is_not_asked_for(): + cfg = Config.defaults() + cfg.session.tidal.quality = 2 # e.g. `streamrip --quality 2` + assert TidalClient(cfg).hires_lane is None + + +def test_each_lane_keeps_its_own_tokens(): + c = _client(hires=True) + c.tokens.access_token = "lossless" + c.hires_lane.tokens.access_token = "hires" + assert (c.config.access_token, c.config.hires_access_token) == ("lossless", "hires") + + +def test_explicit_client_override_wins(): + cfg = Config.defaults() + cfg.session.tidal.hires_client = True + cfg.session.tidal.client_id = "abc" + cfg.session.tidal.client_secret = "xyz" + assert TidalClient(cfg).client_id == "abc" + + +@pytest.mark.asyncio +async def test_token_from_other_client_requires_new_login(monkeypatch): + c = _client() + c.config.access_token = "token" + c.config.token_client_id = HIRES_CLIENT_ID + monkeypatch.setattr("streamrip.client.tidal.new_session", lambda **_: MagicMock()) + with pytest.raises(MissingCredentialsError): + await c.login() + + +@pytest.mark.asyncio +async def test_dash_manifest_becomes_segmented_downloadable(): + c = _client() + c.session = MagicMock() + c._api_request = AsyncMock( + return_value={ + "manifestMimeType": "application/dash+xml", + "manifest": base64.b64encode(MPD.encode()).decode(), + } + ) + dl = await c.get_downloadable("1", 3) + assert isinstance(dl, TidalDASHDownloadable) + assert dl.extension == "flac" + assert dl.init_url == "https://sp.example/init.mp4" + assert dl.segment_urls == [f"https://sp.example/{n}.mp4" for n in range(1, 5)] + + +@pytest.mark.asyncio +async def test_json_manifest_still_supported(): + c = _client() + c.session = MagicMock() + manifest = ( + '{"urls": ["https://x/y.flac"], "codecs": "flac", "encryptionType": "NONE"}' + ) + c._api_request = AsyncMock( + return_value={ + "manifestMimeType": "application/vnd.tidal.bts", + "manifest": base64.b64encode(manifest.encode()).decode(), + } + ) + dl = await c.get_downloadable("1", 2) + assert isinstance(dl, TidalDownloadable) + assert dl.extension == "flac" + + +@pytest.mark.asyncio +async def test_missing_manifest_is_non_streamable(): + c = _client() + c._api_request = AsyncMock(return_value={"userMessage": "Asset is not ready"}) + with pytest.raises(NonStreamableError, match="not ready"): + await c.get_downloadable("1", 2) + + +@pytest.mark.asyncio +async def test_warns_when_lossless_is_requested_but_lossy_is_served(caplog): + c = _client() + c.session = MagicMock() + manifest = ( + '{"urls": ["https://x/y.m4a"], "codecs": "mp4a.40.2", "encryptionType": "NONE"}' + ) + c._api_request = AsyncMock( + return_value={ + "audioQuality": "HIGH", + "manifestMimeType": "application/vnd.tidal.bts", + "manifest": base64.b64encode(manifest.encode()).decode(), + } + ) + await c.get_downloadable("1", 2) # requested LOSSLESS, only AAC available + assert "requested LOSSLESS but Tidal only has HIGH" in caplog.text + + +@pytest.mark.asyncio +async def test_hires_request_served_lossless_is_not_a_warning(caplog): + """Quality 3 is "best available", so most tracks legitimately get LOSSLESS.""" + c = _client() + c.session = MagicMock() + manifest = ( + '{"urls": ["https://x/y.flac"], "codecs": "flac", "encryptionType": "NONE"}' + ) + c._api_request = AsyncMock( + return_value={ + "audioQuality": "LOSSLESS", + "manifestMimeType": "application/vnd.tidal.bts", + "manifest": base64.b64encode(manifest.encode()).decode(), + } + ) + with caplog.at_level(logging.DEBUG, logger="streamrip"): + await c.get_downloadable("1", 3) + assert [r for r in caplog.records if r.levelno >= logging.WARNING] == [] + assert "no hi-res master" in caplog.text + + +@pytest.mark.asyncio +async def test_no_warning_when_served_quality_matches_or_exceeds_request(caplog): + c = _client() + c.session = MagicMock() + manifest = ( + '{"urls": ["https://x/y.flac"], "codecs": "flac", "encryptionType": "NONE"}' + ) + c._api_request = AsyncMock( + return_value={ + "audioQuality": "LOSSLESS", + "manifestMimeType": "application/vnd.tidal.bts", + "manifest": base64.b64encode(manifest.encode()).decode(), + } + ) + # Should not raise or warn: got exactly what was requested. + dl = await c.get_downloadable("1", 2) + assert "requested" not in caplog.text + assert isinstance(dl, TidalDownloadable) + + +def _bts(quality: str, codec: str = "flac") -> dict: + manifest = ( + f'{{"urls": ["https://x/y"], "codecs": "{codec}", "encryptionType": "NONE"}}' + ) + return { + "audioQuality": quality, + "manifestMimeType": "application/vnd.tidal.bts", + "manifest": base64.b64encode(manifest.encode()).decode(), + } + + +def _two_lanes(hires_reply, lossless_reply) -> TidalClient: + c = _client(hires=True) + c.session = c.hires_lane.session = MagicMock() + for lane, reply in ((c.hires_lane, hires_reply), (c, lossless_reply)): + if isinstance(reply, Exception): + lane._api_request = AsyncMock(side_effect=reply) + else: + lane._api_request = AsyncMock(return_value=reply) + return c + + +@pytest.mark.asyncio +async def test_hires_is_taken_when_tidal_has_it(): + hires = { + "audioQuality": "HI_RES_LOSSLESS", + "manifestMimeType": "application/dash+xml", + "manifest": base64.b64encode(MPD.encode()).decode(), + } + c = _two_lanes(hires, AssertionError("the lossless client must not be asked")) + assert isinstance(await c.get_downloadable("1", 3), TidalDASHDownloadable) + + +@pytest.mark.asyncio +async def test_no_hires_master_steps_down_to_the_lossless_client(): + # The hi-res client is only ever served AAC for such a track. + c = _two_lanes(_bts("HIGH", "mp4a.40.2"), _bts("LOSSLESS")) + dl = await c.get_downloadable("1", 3) + assert isinstance(dl, TidalDownloadable) + assert dl.extension == "flac" + c.hires_lane._api_request.assert_awaited_once() + c._api_request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_failed_hires_request_falls_back_and_says_so(caplog): + c = _two_lanes(RuntimeError("boom"), _bts("LOSSLESS")) + dl = await c.get_downloadable("1", 3) + assert dl.extension == "flac" + assert "hi-res request failed (boom); using lossless" in caplog.text + + +@pytest.mark.asyncio +async def test_hires_client_is_not_asked_below_quality_3(): + c = _two_lanes(AssertionError("not for CD quality"), _bts("LOSSLESS")) + assert (await c.get_downloadable("1", 2)).extension == "flac" + c.hires_lane._api_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_track_without_the_hires_tag_never_asks_the_hires_client(): + c = _two_lanes(AssertionError("no hi-res master, so not asked"), _bts("LOSSLESS")) + c._note_hires_tags([{"id": 1, "mediaMetadata": {"tags": ["LOSSLESS"]}}]) + assert (await c.get_downloadable("1", 3)).extension == "flac" + c.hires_lane._api_request.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "track", + [ + {"id": 1, "mediaMetadata": {"tags": ["LOSSLESS", "HIRES_LOSSLESS"]}}, + {"id": 1}, # no tag data: still asked, so hi-res is never missed + ], +) +async def test_hires_tagged_or_untagged_tracks_are_asked(track): + c = _two_lanes(_bts("HIGH", "mp4a.40.2"), _bts("LOSSLESS")) + c._note_hires_tags([track]) + await c.get_downloadable("1", 3) + c.hires_lane._api_request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_album_items_teach_which_tracks_have_no_hires_master(): + c = _client(hires=True) + tagged = {"id": 7, "mediaMetadata": {"tags": ["LOSSLESS"]}} + c._api_request = AsyncMock( + return_value={ + "totalNumberOfItems": 2, + "items": [ + {"type": "track", "item": tagged}, + {"type": "track", "item": {"id": 8}}, + ], + } + ) + await c._get_tracks("albums/1") + assert c._no_hires == {"7"} + + +def _album_replies(tags: list[str]): + """An album with two tracks, as `_api_request` would answer for it.""" + album = {"id": 1, "mediaMetadata": {"tags": tags}} + items = { + "totalNumberOfItems": 2, + "items": [{"type": "track", "item": {"id": i}} for i in (11, 12)], + } + + async def reply(path, *_, **__): + return items if path.endswith("/items") else dict(album) + + return reply + + +def _hires_stream(rate: int) -> dict: + return { + "audioQuality": "HI_RES_LOSSLESS", + "bitDepth": 24, + "sampleRate": rate, + "manifestMimeType": "application/dash+xml", + "manifest": base64.b64encode(MPD.encode()).decode(), + } + + +@pytest.mark.asyncio +async def test_hires_album_reports_its_real_format_for_the_folder_name(): + c = _client(hires=True) + c._api_request = _album_replies(["LOSSLESS", "HIRES_LOSSLESS"]) + c.hires_lane._api_request = AsyncMock(return_value=_hires_stream(96000)) + album = await c.get_metadata("1", "album") + assert album["streamQuality"] == {"bitDepth": 24, "sampleRate": 96000} + c.hires_lane._api_request.assert_awaited_once() # one request for the album + + +@pytest.mark.asyncio +async def test_ordinary_album_costs_no_extra_request_and_keeps_its_label(): + c = _client(hires=True) + c._api_request = _album_replies(["LOSSLESS"]) + c.hires_lane._api_request = AsyncMock(side_effect=AssertionError("not hi-res")) + assert "streamQuality" not in await c.get_metadata("1", "album") + + +@pytest.mark.asyncio +async def test_album_format_lookup_failure_only_leaves_the_default_label(): + c = _client(hires=True) + c._api_request = _album_replies(["LOSSLESS", "HIRES_LOSSLESS"]) + c.hires_lane._api_request = AsyncMock(side_effect=RuntimeError("429")) + assert "streamQuality" not in await c.get_metadata("1", "album") + + +@pytest.mark.asyncio +async def test_no_format_lookup_without_the_hires_client(): + c = _client() + c._api_request = _album_replies(["LOSSLESS", "HIRES_LOSSLESS"]) + assert "streamQuality" not in await c.get_metadata("1", "album") + + +@pytest.mark.asyncio +async def test_nothing_streamable_anywhere_still_raises(): + c = _two_lanes({"userMessage": "Asset is not ready"}, {"userMessage": "nope"}) + with pytest.raises(NonStreamableError, match="nope"): + await c.get_downloadable("1", 3) + + +@pytest.mark.asyncio +async def test_single_search_hit_is_returned(): + c = _client() + c._api_request = AsyncMock(return_value={"items": [{"id": 1}]}) + assert await c.search("track", "q", limit=1) == [{"items": [{"id": 1}]}] + + +def _artist_replies(albums, eps=None): + """Tidal splits an artist's discography across two endpoints; both are + plain {"items": [...]} lists of album summaries.""" + + async def reply(path, params=None, **_): + if path.endswith("/albums"): + is_eps = params and params.get("filter") == "EPSANDSINGLES" + return {"items": (eps or []) if is_eps else albums} + return {"name": "Test Artist"} + + return reply + + +@pytest.mark.asyncio +async def test_artist_albums_drop_the_clean_copy_of_an_explicit_duplicate(): + """Tidal sometimes lists one album twice: once clean, once explicit.""" + albums = [ + {"id": "1", "title": "STANS", "numberOfTracks": 12, "explicit": False}, + {"id": "2", "title": "STANS", "numberOfTracks": 12, "explicit": True}, + ] + c = _client() + c._api_request = AsyncMock(side_effect=_artist_replies(albums)) + artist = await c.get_metadata("1", "artist") + assert [a["id"] for a in artist["albums"]] == ["2"] + + +@pytest.mark.asyncio +async def test_artist_albums_keep_the_higher_quality_copy_when_both_explicit(): + """Tidal also lists the same explicit master at two quality tiers.""" + albums = [ + { + "id": "1", + "title": "Coup De Grâce", + "numberOfTracks": 19, + "explicit": True, + "audioQuality": "LOSSLESS", + }, + { + "id": "2", + "title": "Coup De Grâce", + "numberOfTracks": 19, + "explicit": True, + "audioQuality": "HI_RES_LOSSLESS", + }, + ] + c = _client() + c._api_request = AsyncMock(side_effect=_artist_replies(albums)) + artist = await c.get_metadata("1", "artist") + assert [a["id"] for a in artist["albums"]] == ["2"] + + +@pytest.mark.asyncio +async def test_artist_albums_ignore_bracket_style_when_matching_titles(): + """Tidal tags the same edition "(Deluxe Edition)" on one release and + "[Deluxe Edition]" on another -- still the same album. + """ + albums = [ + { + "id": "1", + "title": "Recovery (Deluxe Edition)", + "numberOfTracks": 19, + "explicit": False, + }, + { + "id": "2", + "title": "Recovery [Deluxe Edition]", + "numberOfTracks": 19, + "explicit": True, + }, + ] + c = _client() + c._api_request = AsyncMock(side_effect=_artist_replies(albums)) + artist = await c.get_metadata("1", "artist") + assert [a["id"] for a in artist["albums"]] == ["2"] + + +@pytest.mark.asyncio +async def test_artist_albums_with_different_track_counts_are_not_merged(): + """A single sharing a title with an album isn't the same release.""" + albums = [ + {"id": "1", "title": "Houdini", "numberOfTracks": 1, "explicit": True}, + {"id": "2", "title": "Houdini", "numberOfTracks": 12, "explicit": True}, + ] + c = _client() + c._api_request = AsyncMock(side_effect=_artist_replies(albums)) + artist = await c.get_metadata("1", "artist") + assert {a["id"] for a in artist["albums"]} == {"1", "2"} + + +@pytest.mark.asyncio +async def test_artist_album_duplicates_kept_when_prefer_explicit_is_off(): + albums = [ + {"id": "1", "title": "STANS", "numberOfTracks": 12, "explicit": False}, + {"id": "2", "title": "STANS", "numberOfTracks": 12, "explicit": True}, + ] + cfg = Config.defaults() + cfg.session.metadata.prefer_explicit = False + c = TidalClient(cfg) + c._api_request = AsyncMock(side_effect=_artist_replies(albums)) + artist = await c.get_metadata("1", "artist") + assert [a["id"] for a in artist["albums"]] == ["1", "2"] + + +def test_unknown_tidal_quality_does_not_crash(): + assert tidal_quality_id("HI_RES_LOSSLESS") == 3 + assert tidal_quality_id("SOMETHING_NEW") == 2 + assert tidal_quality_id(None) == 0 + + +@pytest.mark.asyncio +async def test_album_items_are_paged_and_videos_left_out(): + total = 250 + + async def api(path, params=None): + if path == "albums/1": + return {"id": 1, "numberOfTracks": total - 1, "numberOfVideos": 1} + offset = (params or {}).get("offset", 0) + return { + "totalNumberOfItems": total, + "items": [ + {"type": "video" if n == 5 else "track", "item": {"id": n}} + for n in range(offset, min(offset + 100, total)) + ], + } + + c = _client() + c._api_request = AsyncMock(side_effect=api) + + album = await c.get_metadata("1", "album") + + assert [t["id"] for t in album["tracks"]] == [n for n in range(total) if n != 5] + # The album, then one request per page of 100 -- no empty page past the end. + assert c._api_request.await_count == 1 + 3 + + +def _mock_login(lane: TidalClient) -> None: + lane.session = MagicMock() + lane._get_device_code = AsyncMock(return_value=("code", "link.tidal.com/X")) + lane._get_auth_status = AsyncMock( + return_value=( + 0, + { + "user_id": 1, + "country_code": "NL", + "access_token": f"at-{lane.client_id}", + "refresh_token": f"rt-{lane.client_id}", + "token_expiry": 123.0, + }, + ) + ) + + +@pytest.mark.asyncio +async def test_prompter_logs_both_lanes_in_and_saves_them(monkeypatch): + monkeypatch.setattr("streamrip.rip.prompter.launch", lambda *_: None) + monkeypatch.setattr("streamrip.rip.prompter.console", MagicMock()) + cfg = Config.defaults() + client = TidalClient(cfg) + for lane in client.lanes(): + _mock_login(lane) + prompter = TidalPrompter(cfg, client) + assert not prompter.has_creds() + + await prompter.prompt_and_login() + + assert prompter.has_creds() + t = cfg.session.tidal + assert (t.access_token, t.token_client_id) == ( + f"at-{DEFAULT_CLIENT_ID}", + DEFAULT_CLIENT_ID, + ) + assert (t.hires_access_token, t.hires_token_client_id) == ( + f"at-{HIRES_CLIENT_ID}", + HIRES_CLIENT_ID, + ) + saved = cfg.file.tidal # what gets written to config.toml + assert saved.access_token == t.access_token + assert saved.hires_access_token == t.hires_access_token + assert saved.user_id == 1 + + +@pytest.mark.asyncio +async def test_prompter_leaves_a_working_login_alone(monkeypatch): + monkeypatch.setattr("streamrip.rip.prompter.launch", lambda *_: None) + monkeypatch.setattr("streamrip.rip.prompter.console", MagicMock()) + cfg = Config.defaults() + client = TidalClient(cfg) + _mock_login(client) + client.hires_lane.tokens.access_token = "still-good" + client.hires_lane._login_lane = AsyncMock() # its saved login works + client.hires_lane._get_device_code = AsyncMock() + + await TidalPrompter(cfg, client).prompt_and_login() + + client.hires_lane._get_device_code.assert_not_called() + assert cfg.session.tidal.hires_access_token == "still-good" + assert cfg.session.tidal.access_token == f"at-{DEFAULT_CLIENT_ID}" diff --git a/tests/test_tidal_lyrics_failure.py b/tests/test_tidal_lyrics_failure.py new file mode 100644 index 00000000..451a4a6c --- /dev/null +++ b/tests/test_tidal_lyrics_failure.py @@ -0,0 +1,72 @@ +"""Lyrics are optional: failing to fetch them must never cost the track. + +Issue #959: a 401 from the lyrics endpoint escaped get_metadata() and every +track in the album was reported as a download error, with only cover.jpg +written. A 404 was already contained; any other request failure was not. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest + +from streamrip.client.tidal import TidalClient +from streamrip.exceptions import ItemNotFoundError + +TRACK = {"id": 1, "title": "t", "album": {"id": 2}} + + +def _client(*, lyrics_error=None, track_error=None): + client = TidalClient.__new__(TidalClient) + client.global_config = MagicMock() + client.global_config.session.conversion.enabled = False + client.config = MagicMock() + + async def request(path, params=None, base=None): + if path.endswith("/lyrics"): + if lyrics_error: + raise lyrics_error + return {"lyrics": "la la"} + if track_error: + raise track_error + return dict(TRACK) + + client._api_request = AsyncMock(side_effect=request) + return client + + +def _http_error(status): + return aiohttp.ClientResponseError( + MagicMock(real_url="https://tidal.com/v1/tracks/1/lyrics"), + (), + status=status, + message="Unauthorized", + ) + + +@pytest.mark.asyncio +async def test_lyrics_401_does_not_abort_track(caplog): + item = await _client(lyrics_error=_http_error(401)).get_metadata("1", "track") + assert item["title"] == "t" + assert "Failed to get lyrics" in caplog.text + + +@pytest.mark.asyncio +async def test_lyrics_timeout_does_not_abort_track(): + item = await _client(lyrics_error=asyncio.TimeoutError()).get_metadata("1", "track") + assert item["title"] == "t" + + +@pytest.mark.asyncio +async def test_no_lyrics_is_still_quiet(caplog): + item = await _client(lyrics_error=ItemNotFoundError("x")).get_metadata("1", "track") + assert item["title"] == "t" + assert "Failed to get lyrics" not in caplog.text + + +@pytest.mark.asyncio +async def test_track_request_failure_still_raises(): + """Only the optional lyrics call is contained -- real failures still surface.""" + with pytest.raises(aiohttp.ClientResponseError): + await _client(track_error=_http_error(401)).get_metadata("1", "track") diff --git a/tests/test_tidal_requests.py b/tests/test_tidal_requests.py new file mode 100644 index 00000000..6b890f61 --- /dev/null +++ b/tests/test_tidal_requests.py @@ -0,0 +1,177 @@ +"""Tidal API requests survive a flaky network and back off from rate limits.""" + +import asyncio +import contextlib +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from streamrip.client.client import new_session +from streamrip.client.tidal import ( + MAX_API_ATTEMPTS, + MAX_RETRY_DELAY, + RATE_LIMIT_PAUSE, + TidalClient, +) +from streamrip.config import Config +from streamrip.exceptions import ItemNotFoundError + + +class _Response: + def __init__(self, status=200, body=None, headers=None): + self.status = status + self.headers = headers or {} + self.url = "https://api.tidalhifi.com/v1/tracks/1" + self._body = body if body is not None else {"ok": True} + + def raise_for_status(self): + if self.status >= 400: + raise aiohttp.ClientResponseError( + MagicMock(real_url=self.url), (), status=self.status + ) + + async def json(self): + return self._body + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _Session: + """Plays back scripted outcomes: a response, or an exception to raise.""" + + def __init__(self, *outcomes): + self.outcomes = list(outcomes) + self.calls = 0 + + def get(self, url, params=None): + self.calls += 1 + outcome = self.outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + +@pytest.fixture +def sleeps(monkeypatch): + slept = [] + + async def fake_sleep(delay): + slept.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + return slept + + +def _client(*outcomes): + c = TidalClient(Config.defaults()) + c.rate_limiter = contextlib.nullcontext() + c.session = _Session(*outcomes) + return c + + +@pytest.mark.asyncio +async def test_connection_error_is_retried(sleeps): + c = _client(aiohttp.ServerDisconnectedError(), _Response(body={"id": 1})) + assert await c._api_request("tracks/1") == {"id": 1} + assert c.session.calls == 2 + assert len(sleeps) == 1 + + +@pytest.mark.asyncio +async def test_timeout_is_retried(sleeps): + c = _client(TimeoutError(), _Response()) + assert await c._api_request("tracks/1") == {"ok": True} + assert c.session.calls == 2 + + +@pytest.mark.asyncio +async def test_server_error_is_retried(sleeps): + c = _client(_Response(503), _Response(502), _Response(body={"id": 2})) + assert await c._api_request("tracks/1") == {"id": 2} + assert c.session.calls == 3 + + +@pytest.mark.asyncio +async def test_gives_up_after_the_last_attempt_and_raises(sleeps): + c = _client(*[aiohttp.ServerDisconnectedError() for _ in range(MAX_API_ATTEMPTS)]) + with pytest.raises(aiohttp.ServerDisconnectedError): + await c._api_request("tracks/1") + assert c.session.calls == MAX_API_ATTEMPTS + assert len(sleeps) == MAX_API_ATTEMPTS - 1 + + +@pytest.mark.asyncio +async def test_persistent_server_error_raises_the_http_error(sleeps): + c = _client(*[_Response(503) for _ in range(MAX_API_ATTEMPTS)]) + with pytest.raises(aiohttp.ClientResponseError) as e: + await c._api_request("tracks/1") + assert e.value.status == 503 + + +@pytest.mark.asyncio +async def test_rate_limit_waits_for_retry_after_and_pauses_later_requests(sleeps): + c = _client(_Response(429, headers={"Retry-After": "7"}), _Response(), _Response()) + await c._api_request("tracks/1") + # This one never saw a 429 itself, but the account is still rate limited. + await c._api_request("tracks/2") + assert c.session.calls == 3 + # +0-2s jitter, so concurrent requests don't all wake and re-trip it together. + assert all(7 <= s < 9 for s in sleeps) + + +@pytest.mark.asyncio +async def test_rate_limit_without_retry_after_uses_a_default_pause(sleeps): + c = _client(_Response(429), _Response()) + await c._api_request("tracks/1") + assert len(sleeps) == 1 + assert RATE_LIMIT_PAUSE <= sleeps[0] < RATE_LIMIT_PAUSE + 2 + + +@pytest.mark.asyncio +async def test_absurd_retry_after_is_capped(sleeps): + c = _client(_Response(429, headers={"Retry-After": "3600"}), _Response()) + await c._api_request("tracks/1") + assert len(sleeps) == 1 + assert MAX_RETRY_DELAY <= sleeps[0] < MAX_RETRY_DELAY + 2 + + +@pytest.mark.asyncio +async def test_not_found_is_not_retried(sleeps): + c = _client(_Response(404)) + with pytest.raises(ItemNotFoundError): + await c._api_request("tracks/1/lyrics") + assert c.session.calls == 1 + assert sleeps == [] + + +@pytest.mark.asyncio +async def test_other_client_errors_are_not_retried(sleeps): + c = _client(_Response(401)) + with pytest.raises(aiohttp.ClientResponseError): + await c._api_request("tracks/1") + assert c.session.calls == 1 + assert sleeps == [] + + +@pytest.mark.asyncio +async def test_ssl_errors_are_not_retried(sleeps): + c = _client(aiohttp.ClientSSLError(MagicMock(), OSError("bad certificate"))) + with pytest.raises(aiohttp.ClientSSLError): + await c._api_request("tracks/1") + assert c.session.calls == 1 + assert sleeps == [] + + +@pytest.mark.asyncio +async def test_sessions_do_not_wait_five_minutes_on_a_stalled_connection(): + session = new_session() + try: + assert session.timeout.total is None + assert session.timeout.sock_read == 30 + finally: + await session.close() diff --git a/tests/test_track.py b/tests/test_track.py index 26b168fa..fb840c17 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -1,13 +1,24 @@ import os import shutil +from unittest.mock import AsyncMock, MagicMock +import mutagen import pytest from util import arun import streamrip.db as db from streamrip.client.downloadable import Downloadable from streamrip.client.qobuz import QobuzClient -from streamrip.media.track import PendingSingle, Track +from streamrip.config import Config +from streamrip.exceptions import NonStreamableError +from streamrip.media.track import PendingSingle, Track, album_folder +from streamrip.metadata import ( + AlbumInfo, + AlbumMetadata, + Covers, + TrackInfo, + TrackMetadata, +) @pytest.mark.skipif( @@ -30,3 +41,195 @@ def test_pending_resolve(qobuz_client: QobuzClient): assert isinstance(t.downloadable, Downloadable) assert t.cover_path is not None shutil.rmtree(dir) + + +FIXTURES = { + "flac": "tests/silence.flac", + "m4a": "tests/silence.m4a", + "alac": "tests/silence_alac.m4a", +} + + +class FakeDownloadable(Downloadable): + """Copies a real audio fixture into place, so Track can tag it afterwards.""" + + def __init__(self, extension: str): + self.extension = extension + self.source = "test" + + async def _download(self, path, callback): + shutil.copy(FIXTURES[self.extension], path) + callback(os.path.getsize(path)) + + async def size(self): + return os.path.getsize(FIXTURES[self.extension]) + + +def _place_copy(path, fixture: str, album: str = "Test Album"): + """An earlier download of a track: real audio, tagged like streamrip does.""" + shutil.copy(FIXTURES[fixture], path) + audio = mutagen.File(path, easy=True) + audio["title"] = "Song" + audio["album"] = album + audio.save() + + +def _make_track(folder: str, extension: str) -> Track: + config = Config.defaults() + config.session.downloads.folder = folder + config.session.cli.progress_bars = False + config.session.filepaths.track_format = "{title}" + + album = AlbumMetadata( + AlbumInfo("1", 2, "flac"), + "Test Album", + "Test Artist", + "2020", + [], + Covers(), + 1, + ) + meta = TrackMetadata( + info=TrackInfo(id="123"), + title="Song", + album=album, + artist="Test Artist", + tracknumber=1, + discnumber=1, + composer=None, + ) + return Track( + meta, + FakeDownloadable(extension), + config, + folder, + None, + db.Database(db.Dummy(), db.Dummy()), + ) + + +def test_lossy_download_skipped_when_lossless_copy_exists(tmp_path): + _place_copy(tmp_path / "Song.flac", "flac") + + arun(_make_track(str(tmp_path), "m4a").rip()) + + assert not (tmp_path / "Song.m4a").exists() + assert (tmp_path / "Song.flac").exists() + + +def test_lossless_copy_of_another_release_does_not_skip(tmp_path): + # Same filename stem, different album: not the same track. + _place_copy(tmp_path / "Song.flac", "flac", album="Some Single") + + arun(_make_track(str(tmp_path), "m4a").rip()) + + assert (tmp_path / "Song.m4a").exists() + + +def test_lossy_download_proceeds_without_a_lossless_copy(tmp_path): + arun(_make_track(str(tmp_path), "m4a").rip()) + + assert (tmp_path / "Song.m4a").exists() + + +def test_lossy_copy_removed_once_lossless_copy_lands(tmp_path): + _place_copy(tmp_path / "Song.m4a", "m4a") + + arun(_make_track(str(tmp_path), "flac").rip()) + + assert (tmp_path / "Song.flac").exists() + assert not (tmp_path / "Song.m4a").exists() + + +def test_lossy_copy_of_another_release_is_kept(tmp_path): + _place_copy(tmp_path / "Song.m4a", "m4a", album="Some Single") + + arun(_make_track(str(tmp_path), "flac").rip()) + + assert (tmp_path / "Song.m4a").exists() + + +def test_alac_copy_is_never_treated_as_lossy(tmp_path): + # ALAC is lossless but shares the .m4a extension with AAC. + _place_copy(tmp_path / "Song.m4a", "alac") + + arun(_make_track(str(tmp_path), "flac").rip()) + + assert (tmp_path / "Song.m4a").exists() + + +class FlacToFlacConverter: + """Stands in for a lossless conversion that keeps the format (downsampling).""" + + lossless = True + + def __init__(self, filename, **_): + self.final_fn = filename + + async def convert(self): + pass + + +def test_lossy_copy_removed_after_lossless_conversion(tmp_path, monkeypatch): + monkeypatch.setattr( + "streamrip.media.track.converter.get", lambda _: FlacToFlacConverter + ) + _place_copy(tmp_path / "Song.m4a", "m4a") + t = _make_track(str(tmp_path), "flac") + t.config.session.conversion.enabled = True + + arun(t.rip()) + + assert (tmp_path / "Song.flac").exists() + assert not (tmp_path / "Song.m4a").exists() + + +@pytest.mark.asyncio +async def test_single_without_download_info_is_kept_for_repair(tmp_path, monkeypatch): + # This used to escape resolve() and never reach the failed database. + monkeypatch.setattr( + "streamrip.media.track.download_artwork", AsyncMock(return_value=(None, None)) + ) + config = Config.defaults() + config.session.downloads.folder = str(tmp_path) + client = MagicMock() + client.source = "deezer" + client.get_metadata = AsyncMock( + return_value={ + "id": 7, + "title": "Song", + "artist": {"name": "Artist"}, + "track_position": 1, + "disk_number": 1, + "album": { + "id": 70, + "title": "Album", + **{f"cover_{s}": "u" for s in ("xl", "big", "medium", "small")}, + }, + } + ) + client.get_downloadable = AsyncMock(side_effect=NonStreamableError("geoblocked")) + database = MagicMock() + database.downloaded.return_value = False + + assert await PendingSingle("7", client, config, database).resolve() is None + database.set_failed.assert_called_once_with("deezer", "track", "7") + + +@pytest.mark.parametrize("restrict", [False, True]) +def test_album_folder_follows_restrict_characters(restrict): + # Singles built their album folder without restrict_characters, so with + # it on they landed beside their album instead of in it. + config = Config.defaults() + config.session.downloads.folder = "/music" + config.session.filepaths.folder_format = "{albumartist} - {title}" + config.session.filepaths.restrict_characters = restrict + album = AlbumMetadata( + AlbumInfo("1", 2, "FLAC"), "Homogénic", "Björk", "1997", [], Covers(), 10 + ) + + folder = album_folder(config, "qobuz", album) + + assert folder == ( + "/music/Bjrk - Homognic" if restrict else "/music/Björk - Homogénic" + )