From 50184b2c532a1164f6e2b0334e1592b5164de35d Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:42:06 +0000 Subject: [PATCH 01/62] build: modernise dependencies so streamrip installs on Python 3.10-3.14 Installing on Python 3.14 failed because Pillow was capped at <11, and 10.4.0 has no cp314 wheels, so pip tried (and failed) to compile it from source (upstream #1032, #953, #904). - Move pyproject.toml to the PEP 621 [project] table (poetry-core 2.x) - Bump every dependency to current releases with 3.14 wheels: Pillow 12, pathvalidate 3, tomlkit 0.15, m3u8 6, aiofiles 25, rich 15, deezer-py 1.3.7, aiohttp 3.14 - Drop appdirs (only used by the dead streamrip/rip/user_paths.py module, now removed) and aiodns (never imported; it only forced Windows onto the SelectorEventLoop) - Drop the Windows SelectorEventLoop policy: that loop cannot spawn subprocesses, so every ffmpeg conversion failed on Windows (upstream #729) - Force aiohttp's ThreadedResolver so a leftover aiodns in an existing virtualenv cannot bring the problem back - Replace aiohttp's deprecated verify_ssl= connector argument with ssl= - Replace unused black/isort/flake8 dev deps with ruff, update pytest stack - Version 2.3.0 (config version bumped too; old configs auto-update) --- poetry.lock | 2742 +++++++++++++++++--------------- pyproject.toml | 105 +- streamrip/__init__.py | 2 +- streamrip/client/client.py | 4 +- streamrip/client/qobuz.py | 4 +- streamrip/config.py | 2 +- streamrip/config.toml | 2 +- streamrip/media/playlist.py | 4 +- streamrip/rip/cli.py | 4 +- streamrip/rip/main.py | 6 +- streamrip/rip/user_paths.py | 20 - streamrip/utils/ssl_utils.py | 4 +- tests/test_config.py | 2 +- tests/test_config.toml | 2 +- tests/test_ssl_verification.py | 8 +- 15 files changed, 1534 insertions(+), 1377 deletions(-) delete mode 100644 streamrip/rip/user_paths.py diff --git a/poetry.lock b/poetry.lock index e1814bd8..d7489f92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,186 +1,199 @@ -# 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.3.3 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" +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" propcache = ">=0.2.0" +typing_extensions = {version = ">=4.4", markers = "python_version < \"3.13\""} 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"}, -] +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} [[package]] name = "async-timeout" @@ -197,280 +210,293 @@ files = [ [[package]] name = "attrs" -version = "25.1.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" 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"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] -[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." +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." optional = false -python-versions = ">=3.9" +python-versions = "<3.11,>=3.8" groups = ["dev"] +markers = "python_version == \"3.10\"" 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 = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, ] -[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" -description = "Python package for providing Mozilla's CA Bundle." +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" optional = false -python-versions = ">=3.6" +python-versions = ">3" groups = ["main"] +markers = "python_version == \"3.10\"" files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, + {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, ] [[package]] -name = "cffi" -version = "1.17.1" -description = "Foreign Function Interface for Python calling C code." +name = "certifi" +version = "2026.7.22" +description = "Python package for providing Mozilla's CA Bundle." 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 +521,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,14 +530,14 @@ 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] @@ -519,218 +545,309 @@ requests = "*" [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["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"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + [package.extras] test = ["pytest (>=6)"] [[package]] -name = "flake8" -version = "3.9.2" -description = "the modular source code checker: pep8 pyflakes and co" +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -groups = ["dev"] +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, - {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, + {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.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" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.8" +name = "greenlet" +version = "3.5.6" +description = "Lightweight in-process concurrent programming" +optional = true +python-versions = ">=3.10" groups = ["main"] +markers = "extra == \"qobuz-login\"" 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 = "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] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + [[package]] name = "idna" -version = "3.10" +version = "3.20" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"}, + {file = "idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +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 = "iniconfig" -version = "2.0.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "iso8601" -version = "2.1.0" -description = "Simple module to parse ISO 8601 dates" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, - {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, -] - -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -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 = "*" +backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} [[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 +855,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,104 +876,183 @@ 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] @@ -877,497 +1060,436 @@ 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`." -optional = false -python-versions = ">=3.8" -groups = ["dev"] +name = "playwright" +version = "1.63.0" +description = "A high-level API to automate web browsers" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"qobuz-login\"" 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" -optional = false +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 = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"qobuz-login\"" 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 +1497,59 @@ 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\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [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" +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.4,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [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 +1560,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"}, + {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]] -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"}, -] - -[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,7 +1634,7 @@ 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"}, @@ -1534,106 +1642,97 @@ files = [ [[package]] name = "tomli" -version = "2.2.1" +version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["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"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] [[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" +python-versions = ">=3.9" groups = ["main", "dev"] -markers = "python_version == \"3.10\"" 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"}, ] +markers = {main = "python_version < \"3.13\" or extra == \"qobuz-login\"", dev = "python_version < \"3.13\""} [[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 +1743,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] +qobuz-login = ["playwright"] ssl = ["certifi"] [metadata] lock-version = "2.1" -python-versions = ">=3.10 <4.0" -content-hash = "66008fa225aea220c062e58b547d56492a867505c0fd225637c0d57af9e784ff" +python-versions = ">=3.10,<4.0" +content-hash = "0060b1f3b9a9d8c2ef4c8365120af7d54ea1a36a26d48d88d802c62e7ccbbd38" diff --git a/pyproject.toml b/pyproject.toml index c18eb67f..4c5683f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,71 +1,83 @@ -[tool.poetry] +[project] name = "streamrip" -version = "2.2.0" +version = "2.3.0" 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.10,<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.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] -packages = [{ include = "streamrip" }] +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", +] + +[project.optional-dependencies] +# Use certifi's CA bundle instead of the system certificates. +ssl = ["certifi>=2024.7.4"] +# Capture the Qobuz user_auth_token from a real browser login. +qobuz-login = ["playwright>=1.49"] -[tool.poetry.scripts] +[project.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 } +[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.urls] -"Bug Reports" = "https://github.com/nathom/streamrip/issues" +[tool.poetry] +packages = [{ include = "streamrip" }] -[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 = "py310" + [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"] @@ -90,6 +102,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/streamrip/__init__.py b/streamrip/__init__.py index 08d9db88..abc99d4a 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -2,4 +2,4 @@ from .config import Config __all__ = ["Config", "converter", "db", "exceptions", "media", "metadata"] -__version__ = "2.2.0" +__version__ = "2.3.0" diff --git a/streamrip/client/client.py b/streamrip/client/client.py index 53eb08a4..ac9d1a8a 100644 --- a/streamrip/client/client.py +++ b/streamrip/client/client.py @@ -58,7 +58,9 @@ async def get_session( # Get connector kwargs based on SSL verification setting connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=verify_ssl) - connector = aiohttp.TCPConnector(**connector_kwargs) + connector = aiohttp.TCPConnector( + **connector_kwargs, resolver=aiohttp.ThreadedResolver() + ) return aiohttp.ClientSession( headers={"User-Agent": DEFAULT_USER_AGENT} | headers, diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 734e2b82..22ce93d0 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -130,7 +130,9 @@ async def __aenter__(self): # For the spoofer, always use SSL verification connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=True) - connector = aiohttp.TCPConnector(**connector_kwargs) + connector = aiohttp.TCPConnector( + **connector_kwargs, resolver=aiohttp.ThreadedResolver() + ) self.session = aiohttp.ClientSession(connector=connector) return self diff --git a/streamrip/config.py b/streamrip/config.py index bafa5687..7ff446a4 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -17,7 +17,7 @@ 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.0" class OutdatedConfigError(Exception): diff --git a/streamrip/config.toml b/streamrip/config.toml index 029115bd..20823274 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -193,6 +193,6 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.2.0" +version = "2.3.0" # Print a message if a new version of streamrip is available check_for_updates = true diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 383f2465..886c15c2 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -361,7 +361,9 @@ async def fetch(session: aiohttp.ClientSession, url, **kwargs): # 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) + connector = aiohttp.TCPConnector( + **connector_kwargs, resolver=aiohttp.ThreadedResolver() + ) async with aiohttp.ClientSession(connector=connector) as session: page = await fetch(session, playlist_url) diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 2b3f532e..3e473e5a 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -459,7 +459,9 @@ async def latest_streamrip_version(verify_ssl: bool = True) -> tuple[str, str | """ # Create connector with appropriate SSL settings connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=verify_ssl) - connector = aiohttp.TCPConnector(**connector_kwargs) + connector = aiohttp.TCPConnector( + **connector_kwargs, resolver=aiohttp.ThreadedResolver() + ) async with aiohttp.ClientSession(connector=connector) as s: async with s.get("https://pypi.org/pypi/streamrip/json") as resp: diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 562773fc..35019196 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -27,8 +27,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: 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/ssl_utils.py b/streamrip/utils/ssl_utils.py index e1a912a8..d897dfc9 100644 --- a/streamrip/utils/ssl_utils.py +++ b/streamrip/utils/ssl_utils.py @@ -49,13 +49,13 @@ def get_aiohttp_connector_kwargs(verify_ssl=True): Dictionary of kwargs to pass to aiohttp.TCPConnector """ if not verify_ssl: - return {"verify_ssl": False} + return {"ssl": False} if HAS_CERTIFI: ssl_context = create_ssl_context(verify=True) return {"ssl": ssl_context} else: - return {"verify_ssl": True} + return {"ssl": True} def print_ssl_error_help(): diff --git a/tests/test_config.py b/tests/test_config.py index 44742b95..a170376e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -136,7 +136,7 @@ def test_config_file_update(): 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 toml["misc"]["version"] == "2.3.0" # type: ignore assert "YouTubeVideos" in str(toml["youtube"]["video_downloads_folder"]) # type: ignore os.remove("tests/test_config_old2.toml") diff --git a/tests/test_config.toml b/tests/test_config.toml index a33a1864..697df551 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -191,5 +191,5 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.2.0" +version = "2.3.0" check_for_updates = true diff --git a/tests/test_ssl_verification.py b/tests/test_ssl_verification.py index 85ccfb6e..b9800c27 100644 --- a/tests/test_ssl_verification.py +++ b/tests/test_ssl_verification.py @@ -105,7 +105,7 @@ def test_get_aiohttp_connector_kwargs_without_verification(): kwargs = get_aiohttp_connector_kwargs(verify_ssl=False) # It should return kwargs with verify_ssl=False - assert kwargs == {"verify_ssl": False} + assert kwargs == {"ssl": False} def test_client_get_session_supports_verify_ssl(): @@ -139,7 +139,7 @@ async def test_client_get_session_creates_connector(): patch("aiohttp.ClientSession") as mock_client_session, patch("aiohttp.TCPConnector") as mock_connector, ): - mock_get_kwargs.return_value = {"verify_ssl": False} + mock_get_kwargs.return_value = {"ssl": False} mock_connector.return_value = MagicMock() mock_client_session.return_value = AsyncMock() @@ -183,7 +183,7 @@ async def test_latest_streamrip_version_creates_session(): patch("aiohttp.ClientSession") as mock_client_session, patch("aiohttp.TCPConnector") as mock_connector, ): - mock_get_kwargs.return_value = {"verify_ssl": False} + mock_get_kwargs.return_value = {"ssl": False} mock_connector.return_value = MagicMock() # Setup mock responses for API calls @@ -274,7 +274,7 @@ async def test_lastfm_playlist_session_creation(mock_client_session): with patch( "streamrip.utils.ssl_utils.get_aiohttp_connector_kwargs" ) as mock_get_kwargs: - mock_get_kwargs.return_value = {"verify_ssl": False} + mock_get_kwargs.return_value = {"ssl": False} # Try to parse the playlist with pytest.raises(Exception): From c8d50b214df87c823457c431995d2ae146f14564 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Tue, 16 Jun 2026 23:56:55 +0200 Subject: [PATCH 02/62] fix(test): set genre to Pop/Rock in Qobuz fixture to test genre splitting The test asserts both "Pop" and "Rock" are present after parsing, which requires a slash-separated genre name. The fixture had only "Rock", making the split test meaningless and the Pop assertion impossible to pass. (cherry picked from commit 447aebd7ecebaac22359f4f8ac6cf779091879b0) --- tests/qobuz_album_resp.json | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) 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 From 315057d7f41324907603a94d5860173dc24d823f Mon Sep 17 00:00:00 2001 From: Mubashir78 Date: Fri, 3 Jul 2026 21:31:19 +0500 Subject: [PATCH 03/62] Remove unused import in test (F401) (cherry picked from commit d0f0bfb30ea0ce3fd09c34697a74064aad0164f6) --- tests/test_deezer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_deezer.py b/tests/test_deezer.py index 5b7b7d77..a1f5f8cd 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -3,7 +3,6 @@ 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 From 78a0539921b0630acb40ae6d7d69f1a3366ce1da Mon Sep 17 00:00:00 2001 From: izzoa <5224781+izzoa@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:06:28 -0700 Subject: [PATCH 04/62] fix(qobuz): adapt to album/get and playlist/get no longer returning inline tracks Qobuz removed inline track listings from its metadata API (~2026-07-18): album/get no longer returns a "tracks" object (extra=tracks now 400s) and playlist/get returns an always-empty tracks.items. Request the replacement extra=track_ids and fall back to the id list when the legacy shape is absent; ids are hydrated per-track via the existing track/get paths. Fixes #1012 (cherry picked from commit 959ec5d36760654ce9fc83984edd71c548597e7b) --- streamrip/client/qobuz.py | 7 ++++++- streamrip/metadata/playlist.py | 10 ++++++++-- streamrip/metadata/util.py | 4 ++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 22ce93d0..edd34142 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -231,8 +231,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: diff --git a/streamrip/metadata/playlist.py b/streamrip/metadata/playlist.py index 58a563c6..320d00d3 100644 --- a/streamrip/metadata/playlist.py +++ b/streamrip/metadata/playlist.py @@ -3,7 +3,7 @@ from .album import AlbumMetadata from .track import TrackMetadata -from .util import typed +from .util import safe_get, typed NON_STREAMABLE = "_non_streamable" ORIGINAL_DOWNLOAD = "_original_download" @@ -51,7 +51,7 @@ def from_qobuz(cls, resp: dict): name = typed(resp["name"], str) tracks = [] - for i, track in enumerate(resp["tracks"]["items"]): + for i, track in enumerate(safe_get(resp, "tracks", "items", default=[])): meta = TrackMetadata.from_qobuz( AlbumMetadata.from_qobuz(track["album"]), track, @@ -61,6 +61,12 @@ def from_qobuz(cls, resp: dict): continue tracks.append(meta) + if not tracks and resp.get("track_ids"): + # Qobuz's playlist/get returns an empty "tracks" object since July + # 2026; fall back to the id list from the track_ids extra. Each + # track's metadata is fetched by PendingPlaylistTrack.resolve(). + return cls(name, [str(id) for id in resp["track_ids"]]) + return cls(name, tracks) @classmethod diff --git a/streamrip/metadata/util.py b/streamrip/metadata/util.py index 2026b008..d760c24d 100644 --- a/streamrip/metadata/util.py +++ b/streamrip/metadata/util.py @@ -3,6 +3,10 @@ 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"] From 5b8eaf6569d5af0a98e9c69dce8193e6f74cf76b Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:54:08 +0100 Subject: [PATCH 05/62] Report Qobuz API failures instead of asserting; keep credentials out of errors rip search crashed with a bare "AssertionError: 400". The 400 was Qobuz's own search backend failing -- "Impossible to connect, please check your Algolia Application Id." -- and it cleared on its own: the same search succeeded 20 and 40 seconds later. _paginate asserted on the status and threw the message away. Requests that must succeed now go through _request_ok(), which retries once after a short pause when the failure looks transient (a 5xx, or that Algolia message) and otherwise raises APIError carrying Qobuz's own message. The search commands print it instead of a traceback. The 3-second pause is a guess: recovery was only observed at 20 seconds. Credentials also reached error output. Qobuz takes user_id and user_auth_token as URL query parameters, and an HTML error page -- a 502 from Qobuz's edge, seen during the same session -- made aiohttp raise a ContentTypeError quoting the full URL, token included. A failed login interpolated the whole params dict into the exception, and debug logging printed params and the login response. Non-JSON responses are now reported by status, a login answering anything but 200/400/401 raises APIError instead of failing on a missing key, and params are masked wherever they reach a log line or an error. (cherry picked from commit 2d22502950fd7ec5afa77655ef8c0238c516051a) --- streamrip/client/qobuz.py | 76 +++++++++++++++++++----- streamrip/exceptions.py | 7 +++ streamrip/rip/main.py | 19 +++++- tests/test_qobuz_errors.py | 118 +++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 tests/test_qobuz_errors.py diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index edd34142..f83e1d96 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -11,6 +11,7 @@ from ..config import Config from ..exceptions import ( + APIError, AuthenticationError, IneligibleError, InvalidAppIdError, @@ -23,6 +24,15 @@ logger = logging.getLogger("streamrip") +# Qobuz takes credentials as URL query parameters, so they must be masked +# anywhere params reach a log line or an exception message. +_SENSITIVE_PARAMS = ("user_auth_token", "password", "email", "user_id", "request_sig") + + +def _redacted(params: dict) -> dict: + """params with credentials masked, for logs and error messages.""" + return {k: ("" if k in _SENSITIVE_PARAMS else v) for k, v in params.items()} + QOBUZ_BASE_URL = "https://www.qobuz.com/api.json/0.2" QOBUZ_FEATURED_KEYS = { @@ -195,14 +205,22 @@ async def login(self): "app_id": str(c.app_id), } - logger.debug("Request params %s", params) + logger.debug("Request params %s", _redacted(params)) 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}") + raise AuthenticationError( + f"Invalid credentials from params {_redacted(params)}" + ) elif status == 400: - raise InvalidAppIdError(f"Invalid app id from params {params}") + raise InvalidAppIdError(f"Invalid app id from params {_redacted(params)}") + elif status != 200: + raise APIError( + f"Qobuz login failed (HTTP {status}): " + f"{resp.get('message') or 'no message'}" + ) logger.debug("Logged in to Qobuz") @@ -346,6 +364,33 @@ 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, @@ -363,9 +408,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, {}) @@ -387,17 +431,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]]: @@ -451,9 +491,17 @@ 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) + logger.debug("api_request: endpoint=%s, params=%s", epoint, _redacted(params)) 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/exceptions.py b/streamrip/exceptions.py index 687b1a14..a13d359e 100644 --- a/streamrip/exceptions.py +++ b/streamrip/exceptions.py @@ -68,3 +68,10 @@ def print_msg(self, item) -> str: 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. + """ diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 35019196..d60e1eb2 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -9,6 +9,7 @@ from ..client import Client, DeezerClient, QobuzClient, SoundcloudClient, TidalClient from ..config import Config from ..console import console +from ..exceptions import APIError from ..media import ( Media, Pending, @@ -187,7 +188,11 @@ async def search_interactive(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=100) + try: + pages = await client.search(media_type, query, limit=100) + except APIError as e: + console.print(f"[red]Search failed: {e}") + return if len(pages) == 0: console.print(f"[red]No search results found for query {query}") return @@ -238,7 +243,11 @@ 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) + try: + pages = await client.search(media_type, query, limit=1) + except APIError as e: + console.print(f"[red]Search failed: {e}") + return if len(pages) == 0: console.print(f"[red]No search results found for query {query}") @@ -254,7 +263,11 @@ async def search_output_file( ): 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) + try: + pages = await client.search(media_type, query, limit=limit) + except APIError as e: + console.print(f"[red]Search failed: {e}") + return if len(pages) == 0: console.print(f"[red]No search results found for query {query}") diff --git a/tests/test_qobuz_errors.py b/tests/test_qobuz_errors.py new file mode 100644 index 00000000..3a295d30 --- /dev/null +++ b/tests/test_qobuz_errors.py @@ -0,0 +1,118 @@ +"""Qobuz failures should explain themselves, and never quote credentials. + +`rip 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, {})) + with patch.object(QobuzClient, "get_session", new=AsyncMock(return_value=MagicMock())): + with pytest.raises(AuthenticationError) as err: + await c.login() + assert TOKEN not in str(err.value) + assert "" in str(err.value) + + +@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 From 60b6f31f1c78be34faa8527a6e4381d66535e41a Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:56:17 +0100 Subject: [PATCH 06/62] Keep single-track downloads in their disc subfolder Downloading one track of a multi-disc album put it in the album root instead of its "Disc N" subfolder, so it sat beside the disc folders rather than rejoining the album it belongs to. This affects any single-track URL, and every retry made by `rip repair`, which resolves its items as singles. Two causes: PendingSingle.resolve() never applied the disc-subfolder logic that PendingTrack.resolve() already had. Mirror it, gated on add_singles_to_folder so it cannot create a bare "Disc N" directory in the download root. More importantly, AlbumMetadata.from_qobuz() derived disctotal from resp["tracks"]["items"], but the album object embedded in a *track* response is abbreviated and carries no track list, so the computation fell back to 1 for any album reached through a track -- and a disctotal of 1 disables disc subfolders entirely. Prefer "media_count", which is authoritative and present on the abbreviated object, and keep the old computation as a fallback. This also corrects the DISCTOTAL tag written for single-track downloads. (cherry picked from commit 4110837373ef3a10cc6531ae3cb8415fd74b9685) (cherry picked from commit f03957d3c0292f054fb6f39b5ec8ad312c638a66) --- streamrip/media/track.py | 27 ++++++++++++++++++++++----- streamrip/metadata/album.py | 8 +++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/streamrip/media/track.py b/streamrip/media/track.py index bc3207d5..d00f0662 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -227,17 +227,34 @@ async def resolve(self) -> Track | None: 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) + in_album_folder = config.filepaths.add_singles_to_folder + if in_album_folder: + album_folder = self._format_folder(album) else: - folder = parent + album_folder = parent - os.makedirs(folder, exist_ok=True) + os.makedirs(album_folder, exist_ok=True) embedded_cover_path, downloadable = await asyncio.gather( - self._download_cover(album.covers, folder), + self._download_cover(album.covers, album_folder), self.client.get_downloadable(self.id, quality), ) + + # Mirror PendingTrack: a track belonging to a multi-disc album lives in + # that album's disc subfolder. Without this, downloading one track of a + # multi-disc album (`rip repair`, or any single-track URL) drops it + # beside the Disc folders rather than into the one it belongs to. + # Only meaningful when we're actually building the album's folder -- + # otherwise this would create a bare "Disc N" in the download root. + folder = album_folder + if ( + in_album_folder + and config.downloads.disc_subdirectories + and album.disctotal > 1 + ): + folder = os.path.join(album_folder, f"Disc {meta.discnumber}") + os.makedirs(folder, exist_ok=True) + return Track( meta, downloadable, diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index 01b48ed2..30c90c3c 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -103,7 +103,13 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: label = typed(_label or "", str) description = typed(resp.get("description", ""), str) disctotal = typed( - max( + # "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 + # `rip repair`), which then loses the "Disc N" subfolder. + resp.get("media_count") + or max( track.get("media_number", 1) for track in safe_get(resp, "tracks", "items", default=[{}]) # type: ignore ) From 71c62424b808ed396215e01aab97b490e5ce9930 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:57:02 +0100 Subject: [PATCH 07/62] Keep the album edition in the title, tags and folder name Two different editions of the same album could download into the same folder and merge together. A standard and a deluxe release share a title, a year and often a format, so every key available to folder_format rendered identically for both and the second download landed on top of the first. Qobuz returns the edition in a "version" field, which was discarded. Keep it: fold it into the album title unless the title already says it, so "Into the Gap" becomes "Into the Gap (Deluxe Edition)" in the ALBUM tag and in the folder name, and retain it on AlbumMetadata. Expose "tracktotal" and "version" to folder_format as well. Two same-named editions can still collide -- a 25-track and a 39-track release can both be titled "Deluxe Edition" -- and "tracktotal" tells them apart without falling back to the opaque album id. Add "version" to METADATA_TYPES so FLAC gets a standard Vorbis VERSION field. It is deliberately last in the tuple: MP3_KEY and MP4_KEY are built by zipping METADATA_TYPES against their own positional key tuples, so a trailing entry with no counterpart is dropped for those formats, while FLAC_KEY is a comprehension and picks it up. (cherry picked from commit 15cbb8a76d418bec19fecb9cae45a5136f39b843) (cherry picked from commit 096bec1e366dbc526bbd463f49ecd1ae99d2c2c5) --- streamrip/metadata/album.py | 18 +++++++++++++++++- streamrip/metadata/tagger.py | 5 +++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index 30c90c3c..87778ce2 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -50,6 +50,8 @@ class AlbumMetadata: 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,6 +82,8 @@ 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)) @@ -83,6 +91,13 @@ def format_folder_path(self, formatter: str) -> str: @classmethod def from_qobuz(cls, resp: dict) -> AlbumMetadata: album = resp.get("title", "Unknown Album") + version = resp.get("version") + # 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})" 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)))) @@ -162,6 +177,7 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: lyrics=None, purchase_date=None, tracktotal=tracktotal, + version=resp.get("version"), ) @classmethod diff --git a/streamrip/metadata/tagger.py b/streamrip/metadata/tagger.py index aae8e2b5..9903c6da 100644 --- a/streamrip/metadata/tagger.py +++ b/streamrip/metadata/tagger.py @@ -88,6 +88,11 @@ "disctotal", "date", "isrc", + # Keep "version" last: MP3_KEY/MP4_KEY zip this tuple against their own + # positional key tuples, so a trailing entry with no counterpart is simply + # dropped for those formats. FLAC_KEY is built by comprehension and picks + # it up as the standard Vorbis VERSION field. + "version", ) From 34aa766a8651da4b7df992bd6e9319a40a85796a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Sat, 29 Aug 2026 20:34:24 +0200 Subject: [PATCH 08/62] fix(qobuz): auto-refetch app id/secrets on stale credential error Qobuz periodically rotates its app secret. A stale app_id/secret pair (hardcoded in the config, or cached from a previous run) still passes the lenient user/login check but fails secret validation against track/getFileUrl with a 400, surfacing as InvalidAppSecretError (or InvalidAppIdError) and a hard login failure. login() now splits into _attempt_login() and _refresh_app_id_and_secrets(). On InvalidAppIdError/InvalidAppSecretError it refetches a fresh pair from the web bundle, persists it to the config file, and retries once, so a secret rotation self-heals on the next run even when app_id/secrets are pinned in the config. Also relax the Pillow constraint to ">=9". (cherry picked from commit 97ae4215e4e0ab879b93ba251c01fdb74bd1575b) --- streamrip/client/qobuz.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index f83e1d96..cada3d51 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -183,13 +183,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: @@ -232,8 +252,6 @@ async def login(self): 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) From 4426471e6e2c416b1744d333640805fcb8491b3f Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:45:16 +0000 Subject: [PATCH 09/62] fix(qobuz): token login, free-account purchases, robust metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the cherry-picked upstream PRs (#1013, #1037, #1026, #1027, #1031, #1001, #1011) with the parts that had to be merged by hand: - Login: close the aiohttp session when login fails (upstream PR #1005), and give a clear error for a rejected user_auth_token or password - Prompter: Qobuz moved its web login behind OAuth/reCAPTCHA, so password login fails for most accounts (upstream #954, #956, #899). Ask for a user id + user_auth_token (with instructions), keep email/password as a fallback, and optionally capture the token from a real browser login with Playwright (`pip install 'streamrip[qobuz-login]'`, from PR #955) - Free accounts can download albums they purchased (intent=download, from PR #1007, upstream #673) - Playlists longer than 500 tracks: prefer the complete track_ids list and paginate when it is missing (upstream PR #847) - A malformed playlist entry no longer sinks the whole playlist - Missing "performer" / ISRC / genre no longer crash track parsing (upstream #668); album titles are stripped (upstream #902) - get_downloadable reports the HTTP error instead of asserting, and clamps quality 0 to 1 instead of asserting Co-authored-by: berettavexee Co-authored-by: Michael Pölzl Co-authored-by: Jordan Rejaud Co-authored-by: githin jose --- streamrip/client/qobuz.py | 121 +++++++++++++++++--- streamrip/config.toml | 4 + streamrip/metadata/album.py | 13 ++- streamrip/metadata/playlist.py | 31 ++++-- streamrip/metadata/track.py | 12 +- streamrip/rip/prompter.py | 81 +++++++++++--- streamrip/rip/qobuz_token_capture.py | 126 +++++++++++++++++++++ tests/test_meta.py | 4 +- tests/test_qobuz_download_only.py | 158 +++++++++++++++++++++++++++ tests/test_qobuz_errors.py | 8 +- tests/test_qobuz_playlist.py | 73 +++++++++++++ 11 files changed, 576 insertions(+), 55 deletions(-) create mode 100644 streamrip/rip/qobuz_token_capture.py create mode 100644 tests/test_qobuz_download_only.py create mode 100644 tests/test_qobuz_playlist.py diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index cada3d51..b3d59abd 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -13,7 +13,6 @@ from ..exceptions import ( APIError, AuthenticationError, - IneligibleError, InvalidAppIdError, InvalidAppSecretError, MissingCredentialsError, @@ -31,7 +30,10 @@ def _redacted(params: dict) -> dict: """params with credentials masked, for logs and error messages.""" - return {k: ("" if k in _SENSITIVE_PARAMS else v) for k, v in params.items()} + return { + k: ("" if k in _SENSITIVE_PARAMS else v) for k, v in params.items() + } + QOBUZ_BASE_URL = "https://www.qobuz.com/api.json/0.2" @@ -164,17 +166,30 @@ 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 = await self.get_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 @@ -231,8 +246,15 @@ async def _attempt_login(self): logger.debug("Login response keys: %s", sorted(resp)) if status == 401: + 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( - f"Invalid credentials from params {_redacted(params)}" + "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 {_redacted(params)}") @@ -244,8 +266,16 @@ async def _attempt_login(self): 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}) @@ -287,11 +317,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 @@ -363,16 +423,41 @@ async def get_user_playlists(self, limit: int = 500) -> list[dict]: 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 + # `rip --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:])) + ".", ) @@ -407,7 +492,9 @@ async def _request_ok(self, epoint: str, params: dict) -> dict: await asyncio.sleep(3) continue break - raise APIError(f"Qobuz {epoint} failed (HTTP {status}): {message or 'no message'}") + raise APIError( + f"Qobuz {epoint} failed (HTTP {status}): {message or 'no message'}" + ) async def _paginate( self, @@ -491,7 +578,11 @@ 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}" + # 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" + r_sig = f"trackgetFileUrlformat_id{quality}intent{intent}track_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) @@ -500,7 +591,7 @@ async def _request_file_url( "request_sig": r_sig_hashed, "track_id": track_id, "format_id": quality, - "intent": "stream", + "intent": intent, } return await self._api_request("track/getFileUrl", params) diff --git a/streamrip/config.toml b/streamrip/config.toml index 20823274..a6472f70 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -28,6 +28,10 @@ quality = 3 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. +# `pip install 'streamrip[qobuz-login]'` lets streamrip capture them for you. use_auth_token = false # Enter your userid if the above use_auth_token is set to true, else enter your email email_or_userid = "" diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index 87778ce2..8d8bc556 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -90,8 +90,8 @@ def format_folder_path(self, formatter: str) -> str: @classmethod def from_qobuz(cls, resp: dict) -> AlbumMetadata: - album = resp.get("title", "Unknown Album") - version = resp.get("version") + 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 @@ -99,7 +99,8 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: if version and version.lower() not in album.lower(): album = f"{album} ({version})" tracktotal = resp.get("tracks_count", 1) - genre = [safe_get(resp, "genre", "name")] or resp.get("genre") or [] + genre_name = safe_get(resp, "genre", "name") + genre = [genre_name] if isinstance(genre_name, str) else [] genres = list(set(genre_clean.findall("/".join(genre)))) date = resp.get("release_date_original") or resp.get("release_date") year = date[:4] if date is not None else "Unknown" @@ -109,7 +110,9 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: if artists := resp.get("artists"): albumartist = ", ".join(a["name"] for a in artists) else: - albumartist = typed(safe_get(resp, "artist", "name"), str) + albumartist = typed( + safe_get(resp, "artist", "name") or "Unknown Artist", str + ) albumcomposer = typed(safe_get(resp, "composer", "name", default=""), str) _label = resp.get("label") @@ -177,7 +180,7 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: lyrics=None, purchase_date=None, tracktotal=tracktotal, - version=resp.get("version"), + version=version, ) @classmethod diff --git a/streamrip/metadata/playlist.py b/streamrip/metadata/playlist.py index 320d00d3..a4f74d5f 100644 --- a/streamrip/metadata/playlist.py +++ b/streamrip/metadata/playlist.py @@ -50,23 +50,30 @@ def from_qobuz(cls, resp: dict): logger.debug(resp) name = typed(resp["name"], str) tracks = [] - - for i, track in enumerate(safe_get(resp, "tracks", "items", default=[])): - 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") continue tracks.append(meta) - if not tracks and resp.get("track_ids"): - # Qobuz's playlist/get returns an empty "tracks" object since July - # 2026; fall back to the id list from the track_ids extra. Each - # track's metadata is fetched by PendingPlaylistTrack.resolve(). - return cls(name, [str(id) for id in resp["track_ids"]]) - return cls(name, tracks) @classmethod diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index bea7a9b0..b4487897 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -37,7 +37,7 @@ class TrackMetadata: @classmethod def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None: title = typed(resp["title"].strip(), str) - isrc = typed(resp["isrc"], str) + isrc = typed(resp.get("isrc"), str | None) streamable = typed(resp.get("streamable", False), bool) if not streamable: @@ -53,12 +53,12 @@ def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None: 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) + # "performer" is missing on some tracks (upstream #668); fall back to + # the album artist rather than failing the whole track. artist = typed( - safe_get( - resp, - "performer", - "name", - ), + safe_get(resp, "performer", "name") + or safe_get(resp, "album", "artist", "name") + or album.albumartist, str, ) track_id = str(resp["id"]) diff --git a/streamrip/rip/prompter.py b/streamrip/rip/prompter.py index 0cc30b83..293d57a6 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 installed. + + Install with ``pip install 'streamrip[qobuz-login]'`` and run + ``playwright install chromium`` once. + """ + 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() diff --git a/streamrip/rip/qobuz_token_capture.py b/streamrip/rip/qobuz_token_capture.py new file mode 100644 index 00000000..91c5977d --- /dev/null +++ b/streamrip/rip/qobuz_token_capture.py @@ -0,0 +1,126 @@ +"""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 is an optional dependency: ``pip install +'streamrip[qobuz-login]'`` followed by ``playwright install chromium``. +""" + +import asyncio +import importlib.util +import logging +import platform +import time + +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 _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. Install it with " + "`pip install 'streamrip[qobuz-login]'` and run " + "`playwright install chromium`, 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: + 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/tests/test_meta.py b/tests/test_meta.py index 01032baa..a02d8966 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 diff --git a/tests/test_qobuz_download_only.py b/tests/test_qobuz_download_only.py new file mode 100644 index 00000000..bf0fba51 --- /dev/null +++ b/tests/test_qobuz_download_only.py @@ -0,0 +1,158 @@ +"""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. +""" + +import hashlib +from unittest.mock import AsyncMock + +from util import arun + +from streamrip.client.qobuz import QobuzClient +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(client, "get_session", AsyncMock(return_value=_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 (no IneligibleError); 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) + expected_preimage = ( + f"trackgetFileUrlformat_id{format_id}intentdownload" + f"track_id{track_id}{params['request_ts']}{secret}" + ) + assert ( + hashlib.md5(expected_preimage.encode("utf-8")).hexdigest() + == params["request_sig"] + ) + + +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) + expected_preimage = ( + f"trackgetFileUrlformat_id{format_id}intentstream" + f"track_id{track_id}{params['request_ts']}{secret}" + ) + assert ( + hashlib.md5(expected_preimage.encode("utf-8")).hexdigest() + == params["request_sig"] + ) diff --git a/tests/test_qobuz_errors.py b/tests/test_qobuz_errors.py index 3a295d30..9701b06d 100644 --- a/tests/test_qobuz_errors.py +++ b/tests/test_qobuz_errors.py @@ -95,11 +95,15 @@ async def test_failed_login_does_not_quote_the_token(): q.app_id = "987654321" q.secrets = ["s1"] c._api_request = AsyncMock(return_value=(401, {})) - with patch.object(QobuzClient, "get_session", new=AsyncMock(return_value=MagicMock())): + session = MagicMock() + session.close = AsyncMock() + with patch.object(QobuzClient, "get_session", new=AsyncMock(return_value=session)): with pytest.raises(AuthenticationError) as err: await c.login() assert TOKEN not in str(err.value) - assert "" 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 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() From 29a20ec6e08b446e19c5d6755958e2f57ae57c78 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:18:01 +0100 Subject: [PATCH 10/62] Fix crash when Tidal returns 404 logger.warning("TIDAL: track not found", resp) passes an argument to a message with no format placeholder, so logging raises TypeError: not all arguments converted during string formatting as soon as the record is actually formatted. A 404 from the API -- an unavailable or region-restricted item, or a bad id -- therefore crashed with a traceback from inside logging instead of reporting the NonStreamableError on the next line. Add the placeholder and log the URL, which is the useful part. (cherry picked from commit a82a71c5b8f9d6ee55f77b2fa7eab8d44437f89d) --- streamrip/client/tidal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 92455d20..fac8f46b 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -353,7 +353,7 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: 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) + logger.warning("TIDAL: item not found (404): %s", resp.url) raise NonStreamableError("TIDAL: Track not found") resp.raise_for_status() return await resp.json() From 9eba1d2bd463ef4c357795340f3155c3253b9c43 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:25:51 +0100 Subject: [PATCH 11/62] Fix Tidal share links ending in /u Tidal's share sheet produces urls like https://tidal.com/album/152697662/u URL_REGEX takes the last path segment as the item id, which it has to do because Qobuz album urls look like //album//. So the trailing "/u" was parsed as the id, streamrip requested album "u", and the API returned 404. Strip that suffix before matching. It is specific to tidal.com urls, so Qobuz and Deezer paths are untouched. Combined with the 404 handler in the previous commit, the failure was particularly hard to diagnose: the id was silently wrong, and the 404 it caused crashed inside logging rather than reporting itself. Adds a regression test covering the /u, /u/ and /browse/.../u forms. (cherry picked from commit 6f2d5895d17336b106a5572f7e80384b5bc46d5e) --- streamrip/rip/parse_url.py | 7 +++++++ tests/test_parse_url.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index bdfa0b03..172214ba 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -20,6 +20,7 @@ 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( @@ -54,6 +55,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 diff --git a/tests/test_parse_url.py b/tests/test_parse_url.py index 9048cd64..87f67779 100644 --- a/tests/test_parse_url.py +++ b/tests/test_parse_url.py @@ -51,6 +51,28 @@ 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_track_url(self): """Test that Deezer track URLs are matched correctly.""" url = "https://www.deezer.com/track/4195713" From 6d8dea5f5be9f0a40d92ea67c9fac14ab99ad712 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:30:29 +0100 Subject: [PATCH 12/62] Don't drop Tidal tracks that have no lyrics get_metadata() fetches lyrics for tracks, and the lyrics endpoint 404s for any track nobody has written lyrics for -- which is a lot of them. _api_request turns a 404 into NonStreamableError, but the lyrics fetch only caught TypeError, so that error escaped get_metadata(). PendingTrack.resolve() understandably treats a NonStreamableError from get_metadata() as "this track cannot be streamed" and skips it. The result was that a track was dropped from an album download for the sole reason that it had no lyrics: WARNING TIDAL: item not found (404): .../tracks/152697671/lyrics ERROR Track 152697671 not available for stream on tidal Catch NonStreamableError alongside TypeError. Lyrics are optional metadata and must not decide whether audio gets downloaded. (cherry picked from commit 7bfef4c1168b719a549680001f3d6cbea4a66109) --- streamrip/client/tidal.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index fac8f46b..67084dba 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -124,7 +124,12 @@ 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 (NonStreamableError, TypeError) as e: + # Lyrics are optional. The endpoint 404s for any track that + # has none, which _api_request turns into NonStreamableError + # -- previously that escaped get_metadata() and the caller + # dropped the track as unstreamable, so a track was skipped + # for the sole reason that nobody had written lyrics for it. logger.warning(f"Failed to get lyrics for {item_id}: {e}") logger.debug(item) From 18613a37320f04f4bab8df7cbe5642e49dab9f1f Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:20:53 +0100 Subject: [PATCH 13/62] Don't warn about tracks that simply have no lyrics Most tracks have no lyrics, so the lyrics endpoint 404s constantly. That produced two warnings per track -- one from the 404 handler and one from the lyrics handler -- for something entirely normal: WARNING TIDAL: item not found (404): .../tracks/152697671/lyrics WARNING Failed to get lyrics for 152697671: TIDAL: Track not found "Not found" is the expected answer here, not a problem. Worse, the noise makes a real failure indistinguishable from the routine case. Add ItemNotFoundError, a subclass of NonStreamableError raised for 404 so existing handlers are unaffected, and let callers tell "does not exist" from "failed to fetch": - The 404 in _api_request now logs at debug. Callers that care report it themselves at the right level, so it was duplicated anyway. - A 404 fetching lyrics logs at debug: nothing was expected. - Any other lyrics failure still warns, because lyrics that should have been there and could not be fetched is worth knowing about. Verified against three tracks known to have no lyrics: no warning-level output at all, and a simulated non-404 lyrics failure still warns while leaving the track downloadable. (cherry picked from commit cbb41d7034b32bdea232ab55e17e2f786571e033) --- streamrip/client/tidal.py | 20 ++++++++++++-------- streamrip/exceptions.py | 9 +++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 67084dba..7f175e91 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -9,7 +9,7 @@ import aiohttp from ..config import Config -from ..exceptions import NonStreamableError +from ..exceptions import ItemNotFoundError, NonStreamableError from .client import Client from .downloadable import TidalDownloadable @@ -124,12 +124,13 @@ 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 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) as e: - # Lyrics are optional. The endpoint 404s for any track that - # has none, which _api_request turns into NonStreamableError - # -- previously that escaped get_metadata() and the caller - # dropped the track as unstreamable, so a track was skipped - # for the sole reason that nobody had written lyrics for it. + # Lyrics that should have been there but could not be + # fetched -- worth knowing about. logger.warning(f"Failed to get lyrics for {item_id}: {e}") logger.debug(item) @@ -358,7 +359,10 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: async with self.rate_limiter: async with self.session.get(f"{base}/{path}", params=params) as resp: if resp.status == 404: - logger.warning("TIDAL: item not found (404): %s", resp.url) - raise NonStreamableError("TIDAL: Track not found") + # 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}") resp.raise_for_status() return await resp.json() diff --git a/streamrip/exceptions.py b/streamrip/exceptions.py index a13d359e..429cb0b3 100644 --- a/streamrip/exceptions.py +++ b/streamrip/exceptions.py @@ -66,6 +66,15 @@ def print_msg(self, item) -> str: 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.""" From 1921ba5d9022679b7042f9c8d42429a3714bae94 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:43:01 +0100 Subject: [PATCH 14/62] Never let a failed lyrics fetch abort the track Lyrics are optional, but only a 404 from the lyrics endpoint was contained. Any other request failure -- the 401 in #959, a timeout, a 5xx -- escaped get_metadata() and failed the whole track, so an album could come back with nothing but cover.jpg. The lyrics block now also catches aiohttp.ClientError and asyncio.TimeoutError, logging a warning and carrying on without lyrics. A failure of the track request itself is unaffected and still raises. One side effect worth stating: a rate-limited (429) lyrics call now costs the lyrics rather than the track. (cherry picked from commit 38208a8ae8773fc31035d949027f19f31decd62b) --- streamrip/client/tidal.py | 11 ++++- tests/test_tidal_lyrics_failure.py | 72 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/test_tidal_lyrics_failure.py diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 7f175e91..c39f6a33 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -128,9 +128,16 @@ async def get_metadata(self, item_id: str, media_type: str) -> dict: # 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) as e: + except ( + NonStreamableError, + TypeError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as e: # Lyrics that should have been there but could not be - # fetched -- worth knowing about. + # 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) 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") From 5179d6934c7e2eb61523b06767dead8ac3534836 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:51:36 +0100 Subject: [PATCH 15/62] Offer a fresh login when saved credentials stop working has_creds() only checks that something is stored, not that it still works. An expired token is still a non-empty string, so the check passes, login() is attempted, and the failure surfaced as a bare traceback -- even though the prompter that fixes it is already built and reached in the has_creds() == False branch a few lines above. get_logged_in_client now catches AuthenticationError and MissingCredentialsError from login() and offers to re-authorise. Qobuz and Deezer already raise those types, so the only change needed to cover all three sources was typing Tidal's two bare Exceptions -- 'Access token not found in config.' and 'Refresh failed', the latter being what you get when the refresh token itself lapses. Declining re-raises the original error, so nothing is swallowed. Non-interactive runs never prompt. rip is used from cron and from scripts, where a hidden y/n hangs forever instead of failing, so without a tty it raises with an explanation of what to do. This follows the isatty guard already used for artist album selection. Tests cover all three paths and fail without the change. (cherry picked from commit f2dc514851a10dc23ffc88eb11a6c6fdad684405) --- streamrip/client/tidal.py | 19 ++++++++-- streamrip/rip/main.py | 39 +++++++++++++++++-- tests/test_reauth_prompt.py | 74 +++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 tests/test_reauth_prompt.py diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index c39f6a33..13f2ce55 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -9,7 +9,12 @@ import aiohttp from ..config import Config -from ..exceptions import ItemNotFoundError, NonStreamableError +from ..exceptions import ( + AuthenticationError, + ItemNotFoundError, + MissingCredentialsError, + NonStreamableError, +) from .client import Client from .downloadable import TidalDownloadable @@ -55,7 +60,9 @@ async def login(self): ) c = self.config if not c.access_token: - raise Exception("Access token not found in config.") + raise MissingCredentialsError( + "No Tidal access token in config -- Tidal has not been set up yet." + ) self.token_expiry = float(c.token_expiry) self.refresh_token = c.refresh_token @@ -313,7 +320,13 @@ async def _refresh_access_token(self): resp = await self._api_post(f"{AUTH_URL}/token", data, AUTH) 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"] diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index d60e1eb2..42f4d36e 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -2,14 +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 +from ..exceptions import APIError, AuthenticationError, MissingCredentialsError from ..media import ( Media, Pending, @@ -148,13 +150,42 @@ 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: rip 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"): diff --git a/tests/test_reauth_prompt.py b/tests/test_reauth_prompt.py new file mode 100644 index 00000000..98038b6e --- /dev/null +++ b/tests/test_reauth_prompt.py @@ -0,0 +1,74 @@ +"""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(): + """rip 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() From 944a01c56b5620235d634d2d940939d784f34f6a Mon Sep 17 00:00:00 2001 From: hi-imruka Date: Thu, 28 May 2026 20:45:49 -0700 Subject: [PATCH 16/62] fix: handle null copyright field in Tidal track response (cherry picked from commit dd60c6c9d4f03464ec4255f789f07f1de3e5ca48) --- streamrip/metadata/album.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index 8d8bc556..c3592a54 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -409,7 +409,7 @@ def from_tidal_playlist_track_resp(cls, resp: dict) -> AlbumMetadata | None: else: year = "Unknown Year" - _copyright = typed(resp.get("copyright", ""), str) + _copyright = typed(resp.get("copyright") or "", str) artists = typed(resp.get("artists", []), list) albumartist = ", ".join(a["name"] for a in artists) if not albumartist: From 373c7b892d2eae0ceec8512bf582ac1212b851d3 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:49:54 +0000 Subject: [PATCH 17/62] fix(tidal): lossless FLAC by default, hi-res via DASH, safer login Builds on the cherry-picked upstream PRs #1036 (includes #1024), #1029 and #979. - Client id: Tidal decides per OAuth client which formats it streams. The previous id was served AAC 320 for ordinary lossless releases (upstream #966); the one from PR #1017 gets FLAC 16/44.1 for every lossless release but never hi-res. Use it by default, add [tidal] hires_client = true for the 24-bit client, and optional client_id/client_secret overrides. No developer registration needed. - Tokens are bound to the client that issued them, so the client id is now saved with the token; a mismatch asks for a fresh login (via the re-auth prompt from PR #1029) instead of failing to refresh later. - Hi-res tracks served as MPEG-DASH manifests are downloaded and remuxed with ffmpeg instead of silently dropping quality (PR #998, #981, #544) - HI_RES_LOSSLESS or any unknown audioQuality no longer raises KeyError - search() dropped results when there was exactly one hit, and _api_request overrode every caller's limit with 100, so last.fm playlists never found anything on Tidal - Refreshed tokens are persisted, so a run no longer refreshes every time - Missing manifests, timeouts and rejected device logins raise clear errors instead of KeyError/bare Exception; video URLs parse again - aiohttp.BasicAuth (deprecated in 3.14) replaced by a plain header Co-authored-by: dkvdm Co-authored-by: joshtheclipper <166189819+joshtheclipper@users.noreply.github.com> Co-authored-by: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> --- streamrip/client/downloadable.py | 98 ++++++++++++++++- streamrip/client/tidal.py | 176 ++++++++++++++++++++++++++----- streamrip/config.py | 13 ++- streamrip/config.toml | 13 ++- streamrip/metadata/album.py | 24 +---- streamrip/metadata/track.py | 19 +--- streamrip/metadata/util.py | 18 ++++ streamrip/rip/prompter.py | 29 +++-- tests/test_config.toml | 4 + tests/test_tidal.py | 121 +++++++++++++++++++++ 10 files changed, 439 insertions(+), 76 deletions(-) create mode 100644 tests/test_tidal.py diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 3b29b504..20d71697 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -306,6 +306,102 @@ 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): + if shutil.which("ffmpeg") is None: + raise NonStreamableError( + "FFmpeg is required to download Tidal hi-res (DASH) tracks." + ) + 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", + "-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 @@ -396,7 +492,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) ] diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 13f2ce55..f3681753 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -16,18 +16,36 @@ NonStreamableError, ) from .client import Client -from .downloadable import TidalDownloadable +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) + +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) for hi-res releases, but AAC 320 for +# ordinary lossless releases +# +# The default gives lossless for everything. Set [tidal] hires_client = true +# in the config to prefer 24-bit instead, or client_id/client_secret to use +# any other pair (e.g. after Tidal revokes one). +DEFAULT_CLIENT_ID = _b64("NE4zbjZRMXg5NUxMNUs3cA==") +DEFAULT_CLIENT_SECRET = _b64( + "b0tPWGZKVzM3MWNYNnhhWjBQeWhnR05CZE5MbEJaZDRBS0tZb3VnTWppaz0=" +) +HIRES_CLIENT_ID = _b64("ZlgySnhkbW50WldLMGl4VA==") +HIRES_CLIENT_SECRET = _b64( + "MU5tNUFmREFqeHJnSkZKYktOV0xlQXlLR1ZHbUlOdVhQUExIVlhBdnhBZz0=" +) STREAM_URL_REGEX = re.compile( r"#EXT-X-STREAM-INF:BANDWIDTH=\d+,AVERAGE-BANDWIDTH=\d+,CODECS=\"(?!jpeg)[^\"]+\",RESOLUTION=\d+x\d+\n(.+)" ) @@ -36,7 +54,7 @@ 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 } @@ -53,6 +71,20 @@ def __init__(self, config: Config): self.rate_limiter = self.get_rate_limiter( config.session.downloads.requests_per_minute, ) + c = self.config + if c.client_id and c.client_secret: + self.client_id, self.client_secret = c.client_id, c.client_secret + elif c.hires_client: + self.client_id, self.client_secret = HIRES_CLIENT_ID, HIRES_CLIENT_SECRET + else: + self.client_id = DEFAULT_CLIENT_ID + self.client_secret = DEFAULT_CLIENT_SECRET + # 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() + } async def login(self): self.session = await self.get_session( @@ -63,8 +95,21 @@ async def login(self): raise MissingCredentialsError( "No Tidal access token in config -- Tidal has not been set up yet." ) + if c.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 uses one that serves lossless tracks as FLAC " + "instead of AAC, or the config changed it). Please log in again." + ) - self.token_expiry = float(c.token_expiry) + try: + self.token_expiry = float(c.token_expiry) + except (TypeError, ValueError): + self.token_expiry = 0.0 self.refresh_token = c.refresh_token if self.token_expiry - time.time() < 86400: # 1 day @@ -167,11 +212,13 @@ 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): + quality = max(0, min(quality, self.max_quality)) params = { "audioquality": QUALITY_MAP[quality], "playbackmode": "STREAM", @@ -181,11 +228,22 @@ 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}" + ) + + # 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") == "application/dash+xml": + 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." ) @@ -197,12 +255,64 @@ 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"), ) + def _get_downloadable_from_dash(self, track_id: str, resp: dict): + """Build a downloadable from a Tidal MPEG-DASH manifest. + + From upstream PR #998. The manifest lists an init segment and a + SegmentTimeline; every media segment URL is derived from the template. + """ + 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})" + ) + + 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})") + + 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, + ) + async def get_video_file_url(self, video_id: str) -> str: """Get the HLS video stream url. @@ -222,11 +332,10 @@ async def get_video_file_url(self, video_id: str) -> str: ) 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" + available_urls = await resp.text(encoding="utf-8") # Highest resolution is last - *_, last_match = STREAM_URL_REGEX.finditer(available_urls.text) + *_, last_match = STREAM_URL_REGEX.finditer(available_urls) return last_match.group(1) @@ -261,7 +370,7 @@ async def _login_by_access_token(self, token: str, user_id: str): async def _get_login_link(self) -> str: 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) @@ -283,13 +392,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: @@ -312,12 +421,12 @@ 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: # The refresh token itself has lapsed or been revoked, so there is @@ -331,15 +440,25 @@ async def _refresh_access_token(self): c = self.config c.access_token = resp["access_token"] c.token_expiry = resp["expires_in"] + time.time() + if resp.get("refresh_token"): + c.refresh_token = resp["refresh_token"] + # Persist the refreshed token, otherwise every later run refreshes again. + f = self.global_config.file + f.tidal.access_token = c.access_token + f.tidal.token_expiry = c.token_expiry + f.tidal.refresh_token = c.refresh_token + f.set_modified() 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 = await self.get_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) @@ -351,15 +470,16 @@ 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: @@ -374,7 +494,7 @@ 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: diff --git a/streamrip/config.py b/streamrip/config.py index 7ff446a4..2dd0346d 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -50,10 +50,21 @@ class TidalConfig: # 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 + # 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 FLAC, 3: best available (24-bit + # FLAC where the client id is allowed to stream it) quality: int # This will download videos included in Video Albums. download_videos: bool + # Use the OAuth client that is served 24-bit hi-res FLAC. It gets AAC for + # ordinary lossless releases, so the default client (FLAC 16/44.1 for + # everything) is usually the better choice. Changing it requires logging in + # again. + hires_client: bool = False + # Override the OAuth client id/secret entirely (empty = built-in client). + client_id: str = "" + client_secret: str = "" + # The client id the saved tokens were issued to. Do not change. + token_client_id: str = "" @dataclass(slots=True) diff --git a/streamrip/config.toml b/streamrip/config.toml index a6472f70..30013e54 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -43,10 +43,19 @@ 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) quality = 3 # This will download videos included in Video Albums. download_videos = true +# Tidal decides per OAuth client which formats it will stream. The default +# client gets FLAC 16/44.1 for every lossless release but never hi-res. +# Set this to true to use the client that gets 24-bit FLAC for hi-res releases +# (but AAC 320 for ordinary lossless ones). Changing it requires a new login. +hires_client = false +# 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 = "" @@ -57,6 +66,8 @@ refresh_token = "" # 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 = "" +# The client id the tokens above were issued to +token_client_id = "" [deezer] # 0, 1, or 2 diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index c3592a54..ba05488d 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 get_quality_id, safe_get, tidal_quality_id, typed PHON_COPYRIGHT = "\u2117" COPYRIGHT = "\u00a9" @@ -324,7 +324,7 @@ def from_tidal(cls, resp) -> AlbumMetadata | None: 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) + date = typed(resp.get("releaseDate") or "Unknown", str) year = date[:4] _copyright = typed(resp.get("copyright", ""), str) @@ -342,15 +342,7 @@ def from_tidal(cls, resp) -> AlbumMetadata | None: 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] + quality = tidal_quality_id(resp.get("audioQuality", "LOW")) if quality >= 2: sampling_rate = 44100 if quality == 3: @@ -426,15 +418,7 @@ def from_tidal_playlist_track_resp(cls, resp: dict) -> AlbumMetadata | None: 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] + quality = tidal_quality_id(resp.get("audioQuality", "LOW")) if quality >= 2: sampling_rate = 44100 if quality == 3: diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index b4487897..8868279e 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -5,7 +5,7 @@ from typing import Optional from .album import AlbumMetadata -from .util import safe_get, typed +from .util import safe_get, tidal_quality_id, typed logger = logging.getLogger("streamrip") @@ -156,7 +156,7 @@ def from_soundcloud(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata: def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata: title = typed(track["title"], str).strip() item_id = str(track["id"]) - isrc = typed(track["isrc"], str) + isrc = typed(track.get("isrc"), str | None) version = track.get("version") explicit = track.get("explicit", False) if version: @@ -165,7 +165,7 @@ def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata: tracknumber = typed(track.get("trackNumber", 1), int) discnumber = typed(track.get("volumeNumber", 1), int) - artists = track.get("artists") + artists = track.get("artists") or [] if len(artists) > 0: artist = ", ".join(a["name"] for a in artists) else: @@ -173,18 +173,7 @@ def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata: 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 + quality = tidal_quality_id(track.get("audioQuality")) if quality >= 2: sampling_rate = 44100 diff --git a/streamrip/metadata/util.py b/streamrip/metadata/util.py index d760c24d..2dabaed4 100644 --- a/streamrip/metadata/util.py +++ b/streamrip/metadata/util.py @@ -29,6 +29,24 @@ 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 get_quality_id( bit_depth: Optional[int], sampling_rate: Optional[int | float], diff --git a/streamrip/rip/prompter.py b/streamrip/rip/prompter.py index 293d57a6..a511b8a1 100644 --- a/streamrip/rip/prompter.py +++ b/streamrip/rip/prompter.py @@ -152,28 +152,35 @@ def has_creds(self) -> bool: async def prompt_and_login(self): device_code, uri = await self.client._get_device_code() - login_link = f"https://{uri}" + 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 + info: dict = {} + while True: + if time.time() - start > self.timeout_s: + raise AuthenticationError("Timed out waiting for the Tidal login.") status, info = await self.client._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 @@ -181,6 +188,7 @@ async def prompt_and_login(self): c.access_token = info["access_token"] # type: ignore c.refresh_token = info["refresh_token"] # type: ignore c.token_expiry = info["token_expiry"] # type: ignore + c.token_client_id = self.client.client_id self.client._update_authorization_from_config() self.client.logged_in = True @@ -198,6 +206,7 @@ def save(self): cf.access_token = c.access_token cf.refresh_token = c.refresh_token cf.token_expiry = c.token_expiry + cf.token_client_id = c.token_client_id self.config.file.set_modified() diff --git a/tests/test_config.toml b/tests/test_config.toml index 697df551..d6bbbebc 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -43,6 +43,9 @@ secrets = ['secret1', 'secret2'] 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" @@ -53,6 +56,7 @@ refresh_token = "refreshtoken" # 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 = "tokenexpiry" +token_client_id = "" [deezer] # 0, 1, or 2 diff --git a/tests/test_tidal.py b/tests/test_tidal.py new file mode 100644 index 00000000..dbe0b652 --- /dev/null +++ b/tests/test_tidal.py @@ -0,0 +1,121 @@ +import base64 +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 + +MPD = """ + + + + + + + + + + + + + + +""" + + +def _client() -> TidalClient: + return TidalClient(Config.defaults()) + + +def test_default_client_is_the_lossless_one(): + assert _client().client_id == DEFAULT_CLIENT_ID + + +def test_hires_client_can_be_selected(): + cfg = Config.defaults() + cfg.session.tidal.hires_client = True + assert TidalClient(cfg).client_id == HIRES_CLIENT_ID + + +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(): + c = _client() + c.config.access_token = "token" + c.config.token_client_id = HIRES_CLIENT_ID + c.get_session = AsyncMock(return_value=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_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 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 From 69fc0f0ea20748a412334eb1fddb38c28538fde1 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Wed, 19 Aug 2026 00:24:57 +0200 Subject: [PATCH 18/62] fix(deezer): fail fast instead of building a retired CDN URL When get_track_url returns None, get_downloadable fell back to _get_encrypted_file_url, which builds a legacy AES-ECB URL of the form https://e-cdns-proxy-.dzcdn.net/mobile/1/ Deezer has retired that CDN. None of the sixteen possible hosts resolve any more, so the generated URL can never succeed: the download is attempted, retried, and fails with "Cannot connect to host ... [Domain name not found]" -- a DNS error that points at the user's resolver rather than at the real cause. Raising here instead makes the failure immediate and says what actually happened. _get_encrypted_file_url is removed along with the binascii, hashlib and AES imports that only served it. DeezerDownloadable is left untouched: its is_encrypted regex still has to match the modern /media/ CDN, and the extension fallback for URLs without one remains useful as a defensive measure. (cherry picked from commit 9cb53e3c7cc444bddaad5ba82b889d93555a6d5d) --- streamrip/client/deezer.py | 48 +++++++------------------------------- tests/test_deezer.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 056463f9..d5598411 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -1,10 +1,7 @@ import asyncio -import binascii -import hashlib import logging import deezer -from Cryptodome.Cipher import AES from ..config import Config from ..exceptions import ( @@ -202,45 +199,16 @@ async def get_downloadable( ) if url is None: - url = self._get_encrypted_file_url( - item_id, - track_info["MD5_ORIGIN"], - track_info["MEDIA_VERSION"], + # 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, and 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 diff --git a/tests/test_deezer.py b/tests/test_deezer.py index a1f5f8cd..048b468c 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -101,6 +101,49 @@ def test_deezer_fallback_to_lowest_available_quality(mock_deezer_client): # 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 patch.object(mock_deezer_client, 'get_session'): + 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 patch.object(mock_deezer_client, 'get_session'): + 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_fallback_when_disabled(mock_deezer_client): """Unit test: no fallback when lower_quality_if_not_available is False""" # Disable fallback From 9f81612a989b5e8058885defbe83fbe74069c07a Mon Sep 17 00:00:00 2001 From: berettavexee Date: Thu, 20 Aug 2026 07:55:43 +0200 Subject: [PATCH 19/62] fix(deezer): recover delisted tracks through FALLBACK.SNG_ID Getting no URL at any quality is the signature of a delisted old-catalog track: it has not been made unavailable, it has been superseded by another release (usually a remaster), and Deezer names that release in FALLBACK.SNG_ID. That is the same field the WrongGeolocation branch already follows; follow it here too, where the API returns nothing at all rather than "not here". These are exactly the tracks that reached the legacy CDN removed in the previous commit, so the path that could only end in a DNS error now recovers most of them instead. Measured on a 50-track loved-tracks pull: 8 tracks took this path, all 8 came back as FLAC, and all 8 decoded clean under `flac -t` (which verifies the stream MD5 in STREAMINFO, so a wrong Blowfish key would have been caught). The hop is taken once, guarded by the existing is_retry flag, since Deezer's FALLBACK chains can point at another dead entry. The fallback is requested at the caller's quality rather than the one the FILESIZE downgrade loop landed on: a delisted track reports FILESIZE_* = 0 for every tier, so that loop always bottoms out at MP3_128, and passing it on would fetch the replacement at the lowest quality regardless of what it actually offers. Recursing passes the fallback id as item_id, so dl_info["id"] -- from which DeezerDownloadable derives the Blowfish key -- follows the track actually served. (cherry picked from commit 5eaf4e700487bea6d0090794149d1eb330c0e4e5) --- streamrip/client/deezer.py | 27 +++++++++++++++-- tests/test_deezer.py | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index d5598411..48591df5 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -149,6 +149,12 @@ async def get_downloadable( 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 @@ -199,14 +205,31 @@ async def get_downloadable( ) if url is None: + # 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, and the " - "legacy CDN that used to serve as a fallback has been retired.", + "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 diff --git a/tests/test_deezer.py b/tests/test_deezer.py index 048b468c..2514bb10 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -144,6 +144,66 @@ def test_deezer_no_url_error_does_not_leak_a_cdn_url(mock_deezer_client): 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" + ) + + with patch.object(mock_deezer_client, 'get_session'): + 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 patch.object(mock_deezer_client, 'get_session'): + 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 From fe42821a132af634c38603e066c5cea35a952b74 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Sun, 14 Jun 2026 23:43:20 +0200 Subject: [PATCH 20/62] fix(deezer): size requests connection pool to match max_connections The deezer-py library uses a requests.Session with the default urllib3 pool size (10). When concurrent downloads exceed that limit, urllib3 logs a noisy warning for every discarded connection: "Connection pool is full, discarding connection: api.deezer.com" Instead of silencing the warning with setLevel(ERROR), configure the HTTPAdapter pool sizes to match config.session.downloads.max_connections so the pool is never exhausted and no connections are discarded. Idea from https://github.com/hank-bond/streamrip (cherry picked from commit bdb5831c2a7cd8f758d64ae0e61fcedeb89f222b) --- streamrip/client/deezer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 48591df5..5b104b4d 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -2,6 +2,7 @@ import logging import deezer +import requests from ..config import Config from ..exceptions import ( @@ -37,6 +38,17 @@ def __init__(self, config: Config): self.logged_in = False self.config = config.session.deezer + # Size the deezer-py requests session pool to match max_connections so + # urllib3 never needs to discard connections and logs no warnings. + max_conn = config.session.downloads.max_connections + adapter = requests.adapters.HTTPAdapter( + pool_connections=max_conn, + pool_maxsize=max_conn, + 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( From c8566d28303721efacec3a8ea98bc203412f60c1 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Sun, 14 Jun 2026 23:50:25 +0200 Subject: [PATCH 21/62] fix(deezer): set pool_maxsize well above max_connections to prevent overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit max_connections controls download concurrency, but each download spawns several concurrent API calls (metadata, track token, GW info) via asyncio.to_thread(). The number of simultaneous requests.Session calls easily exceeds max_connections, causing urllib3 to discard connections and log warnings. Use max(max_connections * 4, 32) as pool_maxsize — a generous ceiling with no memory cost — so the pool is never exhausted. (cherry picked from commit dd19bc3aabe9933841d832fa5dfee5170b7e3a7b) --- streamrip/client/deezer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 5b104b4d..8bc82293 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -38,12 +38,16 @@ def __init__(self, config: Config): self.logged_in = False self.config = config.session.deezer - # Size the deezer-py requests session pool to match max_connections so - # urllib3 never needs to discard connections and logs no warnings. + # 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_conn = config.session.downloads.max_connections adapter = requests.adapters.HTTPAdapter( pool_connections=max_conn, - pool_maxsize=max_conn, + pool_maxsize=max(max_conn * 4, 32), max_retries=0, ) self.client.session.mount("https://", adapter) From 8c2490688c69e82af4600ed7820bff9eb94d0701 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Tue, 16 Jun 2026 22:08:41 +0200 Subject: [PATCH 22/62] impl: add album cache with debug logging to DeezerClient (cherry picked from commit ee90336a542e9e50fd6df0941563501ccc14867a) --- streamrip/client/deezer.py | 5 +++++ tests/test_deezer.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 8bc82293..6d5ba507 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -37,6 +37,7 @@ def __init__(self, config: Config): self.client = deezer.Deezer() self.logged_in = False self.config = config.session.deezer + self._album_cache = {} # Increase the deezer-py requests session pool well above max_connections. # Each concurrent download spawns several API calls (metadata, track token, @@ -102,12 +103,16 @@ async def get_track(self, item_id: str) -> dict: return item async def get_album(self, item_id: str) -> dict: + if item_id in self._album_cache: + logger.debug(f"Deezer album cache hit for album ID: {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), ) 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: diff --git a/tests/test_deezer.py b/tests/test_deezer.py index 2514bb10..b30372d6 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -226,6 +226,23 @@ def test_deezer_no_fallback_when_disabled(mock_deezer_client): 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( From 59d1eb5d2a1d67e07a7f650ebeab2884af8c5f0f Mon Sep 17 00:00:00 2001 From: berettavexee Date: Tue, 16 Jun 2026 22:17:45 +0200 Subject: [PATCH 23/62] change: elevate cache hit log level to info (cherry picked from commit 864797bf0ce8c566fde0b6d12f203ed7f4e475db) --- streamrip/client/deezer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 6d5ba507..1c188af6 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -104,7 +104,7 @@ async def get_track(self, item_id: str) -> dict: async def get_album(self, item_id: str) -> dict: if item_id in self._album_cache: - logger.debug(f"Deezer album cache hit for album ID: {item_id}") + logger.info(f"Deezer album cache hit for album ID: {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), From 4bcb62d9962f34dd7953319b589675b5ac31a452 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Sun, 14 Jun 2026 13:48:03 +0200 Subject: [PATCH 24/62] fix(deezer): harden metadata parsing against missing API fields Guard two cases that cause KeyError / IndexError on certain Deezer responses: - resp["contributors"] is absent for some tracks/albums (e.g. singles or compilations served by certain regional APIs). Fall back to .get() with an empty list and use resp["artist"]["name"] / safe_get(resp, "artist", "name") as a secondary fallback so the artist field is never empty. - resp["tracks"][-1] raises IndexError on geo-restricted albums where the track list is empty. Default disctotal to 1 in that case. (cherry picked from commit 293057497e7a5e309d9f2eac20576c93535dc66e) --- streamrip/metadata/album.py | 7 +++++-- streamrip/metadata/track.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index ba05488d..24867dfb 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -187,14 +187,17 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: 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) + disctotal = typed(resp["tracks"][-1]["disk_number"], int) if resp["tracks"] else 1 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) + contributors = resp.get("contributors", []) + albumartist = ", ".join( + c["name"] for c in contributors if c["type"] == "artist" + ) or typed(safe_get(resp, "artist", "name"), str) albumcomposer = None label = resp.get("label") booklets = None diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index 8868279e..68a44043 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -95,7 +95,10 @@ def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None: explicit = typed(resp["explicit_lyrics"], bool) work = None title = typed(resp["title"], str) - artist = typed(resp["artist"]["name"], str) + contributors = resp.get("contributors", []) + artist = ", ".join( + c["name"] for c in contributors if c["type"] == "artist" + ) or typed(resp["artist"]["name"], str) tracknumber = typed(resp["track_position"], int) discnumber = typed(resp["disk_number"], int) composer = None From 874dac0785d9c116c71af21cd0d6ca182f4b7c47 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Thu, 25 Jun 2026 21:27:43 +0200 Subject: [PATCH 25/62] fix(deezer): paginate song.getFavoriteIds to fetch all loved tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit song.getFavoriteIds silently caps responses at roughly 25 entries per call regardless of the nb= parameter. The previous implementation called get_user_tracks() / get_my_favorite_tracks() which suffer the same cap (default limit=25 in deezer-py). Users with more than ~25 favorites only had a fraction of their library downloaded. A secondary issue: comparing the URL user_id against logged_in_user_id to detect "own profile" 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, so the comparison always fell into the "other user" branch and called get_user_tracks() without the full limit. - Paginate song.getFavoriteIds via start= until the server returns an empty page. advance start by the actual count returned (not the requested page size) so the loop handles any server page size. - Remove the uid comparison entirely: song.getFavoriteIds carries no user_id parameter and always returns the authenticated account's favorites, making it the correct call regardless of family setup. - Cache logged_in_user_id during login() from get_user_data() for future use without additional round-trips. - Add DeezerFavoriteURL to parse_url.py to support profile liked-track URLs: https://www.deezer.com/{locale}/profile/{user_id}/loved - User with 406 favorites: 6x getFavoriteIds calls (5 data pages of ~100 entries + 1 empty terminator), all 406 tracks resolved. - Family account (child profile id ≠ main account USER_ID): favorites fetched correctly without falling back to the wrong API method. (cherry picked from commit da8dd2c16a600b6a28392ed6c8cbc0f80b48cc20) --- streamrip/client/deezer.py | 54 +++++++++++++++++++++++++++++++++++++- streamrip/rip/parse_url.py | 28 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 1c188af6..6bcdd976 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -26,11 +26,14 @@ class DeezerClient(Client): logged_in: True if logged in config: deezer local config session: aiohttp.ClientSession, used only for track downloads not API requests + logged_in_user_id: USER_ID of the authenticated account, set during login + 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 @@ -38,6 +41,7 @@ def __init__(self, config: Config): self.logged_in = False self.config = config.session.deezer self._album_cache = {} + self.logged_in_user_id: int | None = None # Increase the deezer-py requests session pool well above max_connections. # Each concurrent download spawns several API calls (metadata, track token, @@ -45,7 +49,8 @@ def __init__(self, config: Config): # 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_conn = config.session.downloads.max_connections + # 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), @@ -66,6 +71,8 @@ async def login(self): if not success: raise AuthenticationError self.logged_in = True + user_data = await asyncio.to_thread(self.client.gw.get_user_data) + self.logged_in_user_id = user_data.get("USER", {}).get("USER_ID") async def get_metadata(self, item_id: str, media_type: str) -> dict: # TODO: open asyncio PR to deezer py and integrate @@ -116,6 +123,9 @@ async def get_album(self, item_id: str) -> dict: return album_metadata async def get_playlist(self, item_id: str) -> dict: + if item_id.startswith("favorites:"): + user_id = item_id.removeprefix("favorites:") + return await self.get_user_favorites(user_id) 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), @@ -124,6 +134,48 @@ async def get_playlist(self, item_id: str) -> dict: pl_metadata["track_total"] = len(pl_tracks["data"]) return pl_metadata + 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 + ``logged_in_user_id`` 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( asyncio.to_thread(self.client.api.get_artist, item_id), diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index 172214ba..aba59b94 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -194,6 +194,33 @@ async def _extract_info_from_dynamic_link( raise Exception("Unable to extract Deezer dynamic link.") +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): source = "soundcloud" @@ -239,6 +266,7 @@ def parse_url(url: str) -> URL | None: QobuzInterpreterURL.from_str(url), SoundcloudURL.from_str(url), DeezerDynamicURL.from_str(url), + DeezerFavoriteURL.from_str(url), # TODO: the rest of the url types ] return next((u for u in parsed_urls if u is not None), None) From 66ee3d2a22b9a0b4a7e4148efb1b1a36bef19af0 Mon Sep 17 00:00:00 2001 From: Leonard Slass Date: Tue, 9 Jun 2026 21:58:18 +0200 Subject: [PATCH 26/62] Add lyrics download for Deezer tracks Fetch lyrics from Deezer's GW endpoint (song.getLyrics) in DeezerClient.get_track and thread them through TrackMetadata.from_deezer, mirroring the existing Tidal lyrics flow. Failures are logged and do not abort the download. (cherry picked from commit 1af77053a5d91be1253ec3f0d42a17d293c3ad8c) --- streamrip/client/deezer.py | 8 ++++++++ streamrip/metadata/track.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 6bcdd976..12b0b0a5 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -107,6 +107,14 @@ async def get_track(self, item_id: str) -> dict: album_metadata["track_total"] = len(album_tracks["data"]) item["album"] = album_metadata + try: + lyrics_resp = await asyncio.to_thread( + self.client.gw.get_track_lyrics, item_id + ) + item["lyrics"] = lyrics_resp.get("LYRICS_TEXT") or "" + except Exception as e: + logger.warning(f"Failed to get lyrics for {item_id}: {e}") + return item async def get_album(self, item_id: str) -> dict: diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index 68a44043..7c882c8f 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -102,6 +102,7 @@ def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None: tracknumber = typed(resp["track_position"], int) discnumber = typed(resp["disk_number"], int) composer = None + lyrics = resp.get("lyrics", "") info = TrackInfo( id=track_id, quality=album.info.quality, @@ -119,6 +120,7 @@ def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None: discnumber=discnumber, composer=composer, isrc=isrc, + lyrics=lyrics, ) @classmethod From 0b64bf938fbd981430c747b2fa25d32c855528ae Mon Sep 17 00:00:00 2001 From: Leonard Slass Date: Tue, 9 Jun 2026 22:04:14 +0200 Subject: [PATCH 27/62] Make lyrics fetching optional via [downloads].lyrics Add a `lyrics` option (default true) to the [downloads] config section that gates lyrics fetching for both Deezer and Tidal. The dataclass field is defaulted so configs written before this option still load without a version bump. (cherry picked from commit b3f9ccae6a993daf12f94fe9b42f5ed26398d424) --- streamrip/client/deezer.py | 15 ++++++++------- streamrip/client/tidal.py | 2 +- streamrip/config.py | 3 +++ streamrip/config.toml | 2 ++ tests/test_config.py | 1 + tests/test_config.toml | 2 ++ 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 12b0b0a5..bcdcd858 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -107,13 +107,14 @@ async def get_track(self, item_id: str) -> dict: album_metadata["track_total"] = len(album_tracks["data"]) item["album"] = album_metadata - try: - lyrics_resp = await asyncio.to_thread( - self.client.gw.get_track_lyrics, item_id - ) - item["lyrics"] = lyrics_resp.get("LYRICS_TEXT") or "" - except Exception as e: - logger.warning(f"Failed to get lyrics for {item_id}: {e}") + if self.global_config.session.downloads.lyrics: + try: + lyrics_resp = await asyncio.to_thread( + self.client.gw.get_track_lyrics, item_id + ) + item["lyrics"] = lyrics_resp.get("LYRICS_TEXT") or "" + except Exception as e: + logger.warning(f"Failed to get lyrics for {item_id}: {e}") return item diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index f3681753..b8b4aa65 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -162,7 +162,7 @@ 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": + 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" diff --git a/streamrip/config.py b/streamrip/config.py index 2dd0346d..a2f27e38 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -214,6 +214,9 @@ class DownloadsConfig: # Verify SSL certificates for API connections # Set to false if you encounter SSL certificate verification errors (not recommended) verify_ssl: bool + # Download and embed lyrics (currently supported for Deezer and Tidal) + # Defaulted so configs written before this option don't fail to load + lyrics: bool = True @dataclass(slots=True) diff --git a/streamrip/config.toml b/streamrip/config.toml index 30013e54..623aeb98 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -20,6 +20,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 diff --git a/tests/test_config.py b/tests/test_config.py index a170376e..f0cee2b1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -164,6 +164,7 @@ def test_sample_config_data_fields(sample_config_data): max_connections=6, requests_per_minute=60, verify_ssl=True, + lyrics=True, ), qobuz=QobuzConfig( use_auth_token=False, diff --git a/tests/test_config.toml b/tests/test_config.toml index d6bbbebc..c00a9977 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -20,6 +20,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 From 957aa3f3a2b7b4263fd66bc09219c2af6f44b73f Mon Sep 17 00:00:00 2001 From: Leonard Slass Date: Wed, 10 Jun 2026 10:28:35 +0200 Subject: [PATCH 28/62] Use synced lyrics from Deezer when available Build LRC-formatted text from LYRICS_SYNC_JSON in the song.getLyrics response, falling back to plain LYRICS_TEXT when a track has no sync data. Mirrors the Tidal behavior of using unsynced lyrics when conversion to MP3 is enabled. (cherry picked from commit e19767569f190abe66a43e76268cf1c373fb8528) --- streamrip/client/deezer.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index bcdcd858..ff50242a 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -112,12 +112,33 @@ async def get_track(self, item_id: str) -> dict: lyrics_resp = await asyncio.to_thread( self.client.gw.get_track_lyrics, item_id ) - item["lyrics"] = lyrics_resp.get("LYRICS_TEXT") or "" + # 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: if item_id in self._album_cache: logger.info(f"Deezer album cache hit for album ID: {item_id}") From 2476c723e7aefac9f14776517c2d09c96c814b8d Mon Sep 17 00:00:00 2001 From: berettavexee Date: Sat, 20 Jun 2026 01:10:15 +0200 Subject: [PATCH 29/62] fix(playlist): sanitize playlist names to prevent path traversal via '/' Playlist names containing '/' were passed directly to clean_filepath(), which preserves slashes as path separators, creating unintended subdirectories. Apply clean_filename() first to replace '/' with '-'. Affects PendingPlaylist.resolve() and PendingLastfmPlaylist.resolve(). Albums were already safe: format_folder_path() passes albumartist and title through clean_filename() before template expansion. (cherry picked from commit 6ae424b11419ec491cd70394da0b2032d01ffef2) --- streamrip/media/playlist.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 886c15c2..e1e7164d 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -16,7 +16,7 @@ from ..console import console from ..db import Database from ..exceptions import NonStreamableError -from ..filepath_utils import clean_filepath +from ..filepath_utils import clean_filename, clean_filepath from ..metadata import ( AlbumMetadata, Covers, @@ -171,7 +171,7 @@ async def resolve(self) -> Playlist | None: return None name = meta.name parent = self.config.session.downloads.folder - folder = os.path.join(parent, clean_filepath(name)) + folder = os.path.join(parent, clean_filepath(clean_filename(name))) tracks = [ PendingPlaylistTrack( id, @@ -243,7 +243,7 @@ def callback(): results: list[tuple[str | None, bool]] = await asyncio.gather(*requests) parent = self.config.session.downloads.folder - folder = os.path.join(parent, clean_filepath(playlist_title)) + folder = os.path.join(parent, clean_filepath(clean_filename(playlist_title))) pending_tracks = [] for pos, (id, from_fallback) in enumerate(results, start=1): From fee2070dbd91b5ff287a8528a79c3fbe9c9f7697 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:52:42 +0000 Subject: [PATCH 30/62] fix(deezer): subscription-aware quality, gw playlists, share links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the cherry-picked upstream PRs #1028, #997, #1000, #994, #1008, #984 and #1004. - Never request more than the subscription allows: quality 1 on a free account used to fail with "Deezer HiFi is required for quality 2" (upstream #1015). With lower_quality_if_not_available the quality is clamped (warned once), otherwise a precise error is raised - `rip --quality 3/4` no longer raises IndexError for Deezer - Playlists fall back to the internal gw API when the public API refuses them, which covers private playlists and the new PermissionException on public ones (upstream PRs #973, #945) - Follow redirected album ids whose /tracks endpoint returns no data (upstream #893) - New link.deezer.com/s/... share links, read from the redirect target (upstream #865, #818, PR #887); old dzr.page.link links still work - Blocking deezer-py calls (ARL login, gw.get_track, get_track_url, search) moved off the event loop (PR #987) - Track lyrics are fetched even when the album lookup fails; the album cache is reused for tracks and logs at debug instead of info - A pool size of max_connections = -1 ("no limit") no longer breaks the requests adapter from PR #997 - Missing genres, release dates, contributors, ISRC no longer crash metadata parsing Co-authored-by: Alexander Schäfer Co-authored-by: Jorge Rivera <145457+geekinsanemx@users.noreply.github.com> Co-authored-by: GDjkhp Co-authored-by: berettavexee --- streamrip/client/deezer.py | 157 +++++++++++++++++++++++++++--------- streamrip/metadata/album.py | 17 ++-- streamrip/metadata/track.py | 4 +- streamrip/rip/parse_url.py | 17 +++- tests/test_deezer_client.py | 100 +++++++++++++++++++++++ tests/test_parse_url.py | 33 ++++++++ 6 files changed, 278 insertions(+), 50 deletions(-) create mode 100644 tests/test_deezer_client.py diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index ff50242a..984a19a0 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -42,6 +42,7 @@ def __init__(self, config: Config): self.config = config.session.deezer self._album_cache = {} self.logged_in_user_id: int | None = None + 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, @@ -67,12 +68,16 @@ async def login(self): 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 - user_data = await asyncio.to_thread(self.client.gw.get_user_data) - self.logged_in_user_id = user_data.get("USER", {}).get("USER_ID") + try: + user_data = await asyncio.to_thread(self.client.gw.get_user_data) + self.logged_in_user_id = user_data.get("USER", {}).get("USER_ID") + except Exception as e: + # Only needed for loved-tracks URLs; never worth failing the login. + logger.debug("Could not fetch Deezer user data: %s", e) async def get_metadata(self, item_id: str, media_type: str) -> dict: # TODO: open asyncio PR to deezer py and integrate @@ -93,19 +98,12 @@ 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 - - album_metadata["tracks"] = album_tracks["data"] - album_metadata["track_total"] = len(album_tracks["data"]) - item["album"] = album_metadata + # Geo-restricted or removed albums: tag from the track metadata. + logger.debug(f"Album {album_id} unavailable for track {item_id}: {e}") if self.global_config.session.downloads.lyrics: try: @@ -140,13 +138,28 @@ def _format_synced_lyrics(sync_json: list[dict] | None) -> str: 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.info(f"Deezer album cache hit for album ID: {item_id}") + 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 @@ -156,13 +169,42 @@ async def get_playlist(self, item_id: str) -> dict: if item_id.startswith("favorites:"): user_id = item_id.removeprefix("favorites:") return await self.get_user_favorites(user_id) - 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 + 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. @@ -230,8 +272,8 @@ async def search(self, media_type: str, query: str, limit: int = 200) -> list[di 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 [] @@ -246,9 +288,11 @@ 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; `rip --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") @@ -267,24 +311,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 @@ -293,12 +360,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: @@ -338,3 +405,15 @@ async def get_downloadable( dl_info["url"] = url logger.debug("dz track info: %s", track_info) return DeezerDownloadable(self.session, dl_info) + + 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/metadata/album.py b/streamrip/metadata/album.py index 24867dfb..768c09a1 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -187,10 +187,15 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: 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) if resp["tracks"] else 1 - genres = [typed(g["name"], str) for g in resp["genres"]["data"]] + disctotal = ( + typed(resp["tracks"][-1]["disk_number"], int) if resp["tracks"] else 1 + ) + genres = [ + typed(g["name"], str) + for g in safe_get(resp, "genres", "data", default=[]) or [] + ] - date = typed(resp["release_date"], str) + date = typed(resp.get("release_date") or "Unknown", str) year = date[:4] _copyright = None description = None @@ -469,9 +474,11 @@ def from_incomplete_deezer_track_resp(cls, resp: dict) -> AlbumMetadata | None: album_id = album_resp["id"] album = album_resp["title"] covers = Covers.from_deezer(album_resp) - date = album_resp["release_date"] + date = album_resp.get("release_date") or "Unknown" year = date[:4] - albumartist = ", ".join(a["name"] for a in resp["contributors"]) + albumartist = ", ".join( + a["name"] for a in resp.get("contributors") or [] + ) or typed(safe_get(resp, "artist", "name", default="Unknown Artist"), str) explicit = resp.get("explicit_lyrics", False) info = AlbumInfo( diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index 7c882c8f..4cc766ef 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -89,10 +89,10 @@ def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None: @classmethod def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None: track_id = str(resp["id"]) - isrc = typed(resp["isrc"], str) + isrc = typed(resp.get("isrc"), str | None) bit_depth = 16 sampling_rate = 44.1 - explicit = typed(resp["explicit_lyrics"], bool) + explicit = typed(resp.get("explicit_lyrics", False), bool) work = None title = typed(resp["title"], str) contributors = resp.get("contributors", []) diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index aba59b94..cd16f3fd 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -143,9 +143,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: @@ -186,12 +190,17 @@ 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): diff --git a/tests/test_deezer_client.py b/tests/test_deezer_client.py new file mode 100644 index 00000000..3a9d6868 --- /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(): + """`rip --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_parse_url.py b/tests/test_parse_url.py index 87f67779..d002766b 100644 --- a/tests/test_parse_url.py +++ b/tests/test_parse_url.py @@ -73,6 +73,39 @@ def test_tidal_share_url_with_u_suffix(self): 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" From 1eb83afb30bb9f95e4a09e3143b99e5dfdb4b8a6 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:53:56 +0000 Subject: [PATCH 31/62] fix(soundcloud): find the client id again, stop asserting on streams - The client id scraper expected `', - 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/tests/test_soundcloud.py b/tests/test_soundcloud.py new file mode 100644 index 00000000..60e5d177 --- /dev/null +++ b/tests/test_soundcloud.py @@ -0,0 +1,77 @@ +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") From fd35e50761ed33ee38188798b09136b25360d481 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:54:54 +0100 Subject: [PATCH 32/62] Don't mark failed downloads as downloaded Track.download() retried once and, on a second failure, logged the track to the failed-downloads database but then returned normally. Media.rip() runs preprocess -> download -> postprocess unconditionally, so postprocess() went on to call db.set_downloaded(), permanently marking a track that had never downloaded. Every later run's resolve() then saw it in the downloads database and skipped it, so the track could never be recovered -- including across restarts. Raise TrackDownloadFailedError instead, so Media.rip() stops before postprocess() and set_downloaded() is never reached for a track that actually failed. Also delete the partial file. Downloads always restart from byte 0 (BasicDownloadable opens the path "wb" and sends no Range header), so a partial is never resumed -- it just sits in the library as a truncated file that has valid tags and the right size, plays in any client, and fails to decode partway through. Album.download() and Playlist.download() already wrap each track.rip() in its own try/except and continue, so raising cannot abort a whole album or playlist. (cherry picked from commit 78d76bea26bf27311a444df4a9dba98432b34bbd) (cherry picked from commit e6741f959da7455f24e1d8ddd05124965356dccf) --- streamrip/exceptions.py | 180 ++++++++++++++++++++------------------- streamrip/media/track.py | 12 ++- 2 files changed, 105 insertions(+), 87 deletions(-) diff --git a/streamrip/exceptions.py b/streamrip/exceptions.py index 429cb0b3..09af0f70 100644 --- a/streamrip/exceptions.py +++ b/streamrip/exceptions.py @@ -1,86 +1,94 @@ -"""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 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. - """ +"""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 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/media/track.py b/streamrip/media/track.py index d00f0662..61119221 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -7,7 +7,7 @@ from ..client import Client, Downloadable from ..config import Config from ..db import Database -from ..exceptions import NonStreamableError +from ..exceptions import NonStreamableError, TrackDownloadFailedError from ..filepath_utils import clean_filename from ..metadata import AlbumMetadata, Covers, TrackMetadata, tag_file from ..progress import add_title, get_progress_callback, remove_title @@ -71,6 +71,16 @@ async def download(self): 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(self.meta.title) + raise TrackDownloadFailedError( + f"{self.meta.title} ({self.meta.info.id})" + ) from e async def postprocess(self): if self.is_single: From 84f705d2b2115c9ca21a014424ac53a76c656b63 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:55:26 +0100 Subject: [PATCH 33/62] Record tracks that fail while resolving PendingTrack.resolve() had four failure paths and only one of them recorded anything. If the metadata request failed, if building the metadata raised, or if fetching the downloadable URL failed, it logged a line and returned None. Nothing else happened: no file was written, no downloads-database row was added, and nothing went into the failed database. The track simply went missing from the album, and because it was never recorded as failed, `rip repair` could not find it either. PendingSingle.resolve() had the same gap on three of its paths. Call db.set_failed() on all of them so a resolve-time failure is retryable rather than invisible. (cherry picked from commit bcebf576183b2a7c291186c9701f20928ec1b139) (cherry picked from commit 29de6fbb2a0189e604464dd58c547497c1bbac11) --- streamrip/media/track.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/streamrip/media/track.py b/streamrip/media/track.py index 61119221..c3e3ec5e 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -139,16 +139,22 @@ async def resolve(self) -> Track | None: return None source = self.client.source + # Every failure below has to be recorded, not just logged. An unlogged + # failure leaves no trace anywhere: no file, no downloads.db row, and + # nothing in the failed db for `rip repair` to retry -- the track just + # silently goes missing from the album. 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}") + self.db.set_failed(source, "track", self.id) 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}") + self.db.set_failed(source, "track", self.id) return None if meta is None: @@ -163,6 +169,7 @@ async def resolve(self) -> Track | None: logger.error( f"Error getting downloadable data for track {meta.tracknumber} [{self.id}]: {e}" ) + self.db.set_failed(source, "track", self.id) return None downloads_config = self.config.session.downloads @@ -201,16 +208,20 @@ async def resolve(self) -> Track | None: ) return None + # As in PendingTrack.resolve: record every failure, so a track that + # dies here is retryable by `rip repair` instead of vanishing. try: resp = await self.client.get_metadata(self.id, "track") except NonStreamableError as e: logger.error(f"Error fetching track {self.id}: {e}") + self.db.set_failed(self.client.source, "track", self.id) 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}") + self.db.set_failed(self.client.source, "track", self.id) return None if album is None: @@ -224,6 +235,7 @@ async def resolve(self) -> Track | None: meta = TrackMetadata.from_resp(album, self.client.source, resp) except Exception as e: logger.error(f"Error building track metadata for track {id=}: {e}") + self.db.set_failed(self.client.source, "track", self.id) return None if meta is None: From 6b0d0af5547e9e32628f7e6b29eab8bbbf36c1cf Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:55:42 +0100 Subject: [PATCH 34/62] Add `rip repair` to retry failed downloads The failed-downloads database was write-only: entries went in and nothing ever read them back. config.toml has always told users that "`rip repair` can be called to retry the downloads", but no such command existed. Add it. It reads the failed database, retries each item through the normal resolve/rip pipeline, and clears the ones that succeed, leaving the rest logged for a future run. Success is determined by membership in the downloads database rather than by diffing the failed database before and after, because nothing in the pipeline removes rows from the failed database -- a before/after diff would always report zero repaired. Checking the downloads database is sound because set_downloaded() is only reached from postprocess(), which a failed track no longer gets to. Failed items are cleared from the downloads database before retrying, so a repair still works on a database left inconsistent by an older version. Repaired tracks are placed in their album's folder, since a failed item is usually one track missing from an otherwise complete album, and retrying resolves it as a single. --flat restores the old behaviour of downloading it to the root. (cherry picked from commit 6c54ab0139f3e889241ad375bbb4c39dced9a408) (cherry picked from commit 061e46e9e6f5fbe977df30ed0aca49d423a305c1) --- streamrip/rip/cli.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 3e473e5a..3eef6aa7 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -361,6 +361,87 @@ def database_browse(ctx, table): ) +@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: + await main.add_all_by_id(failed_items) + 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. Instead rely on the + # invariant this patch establishes: set_downloaded() is only reached + # via postprocess(), which a failed download never gets to. So an item + # present 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"[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]rip repair[/bold] to try again." + ) + + @rip.command() @click.option( "-f", From cdb0ef50f6fcd6128798bc776718ddee99966564 Mon Sep 17 00:00:00 2001 From: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:57:35 +0100 Subject: [PATCH 35/62] Repair through the album, not track by track Repair re-added each failed track by id, which resolves as a single. That builds album metadata from the track response -- and on Tidal a track response carries only the album's id, title and cover: album object keys: ['cover', 'id', 'title', 'vibrantColor', 'videoCover'] No track count, so tracktotal falls back to 1, and the artist is the track's artists rather than the album artist. Where folder_format uses {albumartist} or {tracktotal}, repaired tracks therefore land in a different folder from the album they belong to. One repair of a real library produced 18 such folders holding 107 files. Look up each failed track's album and retry through that instead. The metadata is then the album's own, so the folder, disc subfolders and cover art are right, and nothing extra is downloaded: tracks already in the downloads database are skipped. Albums are de-duplicated, so several failures from one album cost one retry rather than several. Anything whose album cannot be determined is still retried on its own. (cherry picked from commit a75065e86321b711969d4a840784b83a5fff1f78) --- streamrip/rip/cli.py | 52 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 3eef6aa7..cc88e77b 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -361,6 +361,38 @@ def database_browse(ctx, table): ) +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( @@ -415,7 +447,25 @@ async def repair(ctx, yes, flat): downloads_db.remove(id=item_id) async with Main(cfg) as main: - await main.add_all_by_id(failed_items) + # 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() From 43f8c451afeef8a24c81f00c244dd1623f6623e2 Mon Sep 17 00:00:00 2001 From: Nicole Portas Date: Mon, 7 Sep 2026 00:44:32 +0100 Subject: [PATCH 36/62] fix: bump http.client._MAXHEADERS to 1000 to prevent CDN header drops (cherry picked from commit f4e2d54c6d009e894abaef7874ec213b8c7211d8) --- streamrip/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/streamrip/__init__.py b/streamrip/__init__.py index abc99d4a..e249344c 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -1,3 +1,8 @@ +import http.client + +# Prevent failures on CDNs (like Qobuz/Akamai) that send >100 response headers +http.client._MAXHEADERS = 1000 + from . import converter, db, exceptions, media, metadata from .config import Config From aaf5932e5f95df89b63347f0b6c63dbdd5219d78 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Sun, 14 Jun 2026 23:27:43 +0200 Subject: [PATCH 37/62] fix: redirect FFmpeg stdin to /dev/null to prevent TTY corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When FFmpeg is launched without stdin redirection it inherits the terminal. Even with -y (auto-overwrite), FFmpeg may alter TTY settings (echo mode, raw mode) for its interactive prompt handling. If it exits without restoring those settings the terminal is left with echo disabled — the user can type but sees nothing. Add stdin=asyncio.subprocess.DEVNULL to both FFmpeg subprocess calls (converter.py and downloadable.py) so FFmpeg never touches the terminal. (cherry picked from commit 0019aec6c261c4962461d7aaca4bf065c98d5624) --- streamrip/client/downloadable.py | 2 +- streamrip/converter.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 15e384af..f99d2b6d 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -519,7 +519,7 @@ 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 diff --git a/streamrip/converter.py b/streamrip/converter.py index aa4aa2aa..7b5f61e2 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -86,6 +86,7 @@ async def convert(self, custom_fn: Optional[str] = None): process = await asyncio.create_subprocess_exec( *self.command, + stdin=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) out, err = await process.communicate() From 5ffa4e50d8af576144a8d969ed16ea2ceedc34e6 Mon Sep 17 00:00:00 2001 From: Timo Stolz Date: Sun, 12 Apr 2026 17:16:52 +0200 Subject: [PATCH 38/62] Respect configured lossy conversion bitrate (cherry picked from commit 04eef6cd500f904a61df8335bd21d7b7c5556994) --- streamrip/converter.py | 24 +++++++++----- streamrip/media/track.py | 5 +++ tests/test_converter.py | 71 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 tests/test_converter.py diff --git a/streamrip/converter.py b/streamrip/converter.py index 7b5f61e2..6f2b2110 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -168,6 +168,10 @@ def _is_command_valid(self): self.ffmpeg_arg = self.default_ffmpeg_arg return + @classmethod + def get_quality_arg(cls, _: int) -> str: + return cls.default_ffmpeg_arg + class FLAC(Converter): """Class for FLAC converter.""" @@ -206,8 +210,9 @@ class LAME(Converter): 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): @@ -233,8 +238,9 @@ class Vorbis(Converter): container = "ogg" 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: @@ -257,8 +263,9 @@ class OPUS(Converter): container = "opus" 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): @@ -275,8 +282,9 @@ class AAC(Converter): container = "m4a" default_ffmpeg_arg = "-b:a 256k" - def get_quality_arg(self, _: int) -> str: - return "" + @classmethod + def get_quality_arg(cls, rate: int) -> str: + return f"-b:a {rate}k" def get(codec: str) -> type[Converter]: diff --git a/streamrip/media/track.py b/streamrip/media/track.py index c3e3ec5e..02054256 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -95,8 +95,13 @@ async def postprocess(self): async def _convert(self): c = self.config.session.conversion engine_class = converter.get(c.codec) + 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 diff --git a/tests/test_converter.py b/tests/test_converter.py new file mode 100644 index 00000000..156921ce --- /dev/null +++ b/tests/test_converter.py @@ -0,0 +1,71 @@ +from util import arun + +from streamrip import converter +from streamrip.config import Config +from streamrip.media.track import Track + + +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" From 6a091fb031457986062df3e1b2bd569535e672e7 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Sun, 14 Jun 2026 22:09:24 +0200 Subject: [PATCH 39/62] fix(converter): properly handle cover art for OGG and OPUS output The OGG and Opus muxers do not support -c:v copy when the source file contains an embedded cover art video stream (e.g. a tagged FLAC). This caused FFmpeg to either fail outright or silently transcode the cover art to Theora, producing an unexpected video stream in the output. This commit fixes the issue with a two-part approach: 1. A new class attribute `_ffmpeg_supports_art` (True by default) lets subclasses signal that their muxer cannot handle video streams. Vorbis and OPUS set it to False. The `_gen_command()` method then: - skips `-c:v copy` for these formats - adds `-vn` to explicitly discard any video stream from the input 2. Cover art is instead embedded post-conversion via mutagen using the METADATA_BLOCK_PICTURE Vorbis comment tag, which both OGG Vorbis and Opus containers support natively. The art is read from the source file *before* conversion starts so it remains available even when `remove_source=True` causes the source to be deleted mid-process. No behaviour change for FLAC, MP3, ALAC, or AAC converters. (cherry picked from commit c12dd9305352129612f004473b620c2fc91657a5) --- streamrip/converter.py | 70 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/streamrip/converter.py b/streamrip/converter.py index 6f2b2110..4215a0f2 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -1,9 +1,11 @@ """Wrapper classes over FFMPEG.""" import asyncio +import base64 import logging import os import shutil +import subprocess from tempfile import gettempdir from typing import Final, Optional @@ -22,6 +24,9 @@ class Converter: 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, @@ -81,6 +86,13 @@ async def convert(self, custom_fn: Optional[str] = None): if custom_fn: self.final_fn = custom_fn + # 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 self.copy_art and not type(self)._ffmpeg_supports_art: + cover_data = await asyncio.to_thread(self._read_source_cover) + self.command = self._gen_command() logger.debug("Generated conversion command: %s", self.command) @@ -97,9 +109,59 @@ async def convert(self, custom_fn: Optional[str] = None): shutil.move(self.tempfile, self.final_fn) logger.debug("Moved: %s -> %s", self.tempfile, self.final_fn) + + if cover_data is not None: + await asyncio.to_thread(self._embed_cover_art, *cover_data) else: raise ConversionError(f"FFmpeg output:\n{out, err}") + 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): command = [ "ffmpeg", @@ -115,8 +177,10 @@ def _gen_command(self): if self.show_progress: command.append("-stats") - if self.copy_art: + if self.copy_art and type(self)._ffmpeg_supports_art: command.extend(["-c:v", "copy"]) + elif not type(self)._ffmpeg_supports_art: + command.append("-vn") if self.ffmpeg_arg: command.extend(self.ffmpeg_arg.split()) @@ -236,6 +300,8 @@ class Vorbis(Converter): 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 @classmethod @@ -261,6 +327,8 @@ class OPUS(Converter): 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 @classmethod From 101c167d87fb6b8e0becd334d8ff8fa712a1d601 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:59:05 +0000 Subject: [PATCH 40/62] fix(core): reliable downloads, conversion and tagging Builds on the cherry-picked upstream PRs #1023 (don't mark failed downloads as downloaded, record resolve failures, `rip repair`), #1033, #996, #960 and #992. Downloads - Up to 4 attempts with exponential backoff; retries continue the partial file with an HTTP Range request instead of starting over (upstream PR #1009, #951, #1022). Resuming is opt-in per attempt, so a re-download never appends to an existing tagged track or a downscaled cover.jpg (a source of corrupt covers, #762) - requests now runs in a worker thread, so one active connection no longer freezes every other download (upstream PR #982); Ctrl-C stops it at the next chunk - aiohttp's multidict headers are copied to plain str before being handed to requests ("Header part ... must be of type str", #941) - aiohttp sessions honour HTTP(S)_PROXY/ALL_PROXY (#961) Conversion - A failed conversion keeps the tagged original and still records the track, instead of re-downloading it every run (#1010) - AAC uses ffmpeg's native encoder when libfdk_aac is missing (PR #990) - Lossy conversions honour lossy_bitrate (#823, PR #960); OPUS and the new AIFF target (PR #1006) are accepted by `rip -c` - Temp files are unique per conversion; tracks with the same name in different albums used to overwrite each other - Converted files are re-tagged, since ffmpeg drops some tags Tagging and paths - [metadata] exclude was never applied; it now is (#850) - MP3s are really written as ID3v2.3: the old call passed the string "v2_version=3" as the v1 argument - File and folder names are shortened by bytes per path component, so long titles no longer fail with "File name too long" (#856, #859) - track_format gains {album}/{albumtitle} and {discnumber} (PR #826) - Error logs include the exception type (#938) and a failed track is not logged twice Co-authored-by: katayanagi Co-authored-by: assafw <13516529+assafw@users.noreply.github.com> Co-authored-by: berettavexee Co-authored-by: Josef Helie --- streamrip/client/client.py | 3 + streamrip/client/downloadable.py | 112 ++++++++++++---- streamrip/client/qobuz.py | 2 +- streamrip/config.py | 2 +- streamrip/config.toml | 8 +- streamrip/converter.py | 62 ++++++++- streamrip/db.py | 6 +- streamrip/filepath_utils.py | 26 +++- streamrip/media/album.py | 8 +- streamrip/media/playlist.py | 14 +- streamrip/media/semaphore.py | 6 +- streamrip/media/track.py | 85 +++++++----- streamrip/metadata/playlist.py | 6 +- streamrip/metadata/search_results.py | 2 +- streamrip/metadata/tagger.py | 68 +++++++--- streamrip/metadata/track.py | 8 +- streamrip/rip/cli.py | 15 ++- streamrip/rip/main.py | 8 +- tests/test_deezer.py | 120 ++++++++++------- tests/test_qobuz_errors.py | 4 +- tests/test_reauth_prompt.py | 8 +- tests/test_resumable_download.py | 187 +++++++++++++++++++++++++++ 22 files changed, 592 insertions(+), 168 deletions(-) create mode 100644 tests/test_resumable_download.py diff --git a/streamrip/client/client.py b/streamrip/client/client.py index ac9d1a8a..deb82f1b 100644 --- a/streamrip/client/client.py +++ b/streamrip/client/client.py @@ -62,7 +62,10 @@ async def get_session( **connector_kwargs, 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} | headers, connector=connector, + trust_env=True, ) diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index f99d2b6d..c006ad84 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -9,6 +9,7 @@ import re import shutil import tempfile +import threading import time from abc import ABC, abstractmethod from dataclasses import dataclass @@ -37,29 +38,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 +125,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 +169,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): @@ -160,7 +221,11 @@ async def _download(self, path: str, callback): 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) @@ -254,6 +319,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) @@ -519,7 +585,9 @@ async def concat_audio_files(paths: list[str], out: str, ext: str, max_files_ope "warning", outpaths[i], ) - fut = asyncio.create_subprocess_exec(*command, stdin=asyncio.subprocess.DEVNULL, 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 diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index b3d59abd..2bb91c20 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -146,7 +146,7 @@ async def __aenter__(self): **connector_kwargs, resolver=aiohttp.ThreadedResolver() ) - self.session = aiohttp.ClientSession(connector=connector) + self.session = aiohttp.ClientSession(connector=connector, trust_env=True) return self async def __aexit__(self, *_): diff --git a/streamrip/config.py b/streamrip/config.py index a2f27e38..6f7e4473 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -117,7 +117,7 @@ class DatabaseConfig: @dataclass(slots=True) class ConversionConfig: enabled: bool - # FLAC, ALAC, OPUS, MP3, VORBIS, or AAC + # FLAC, ALAC, AIFF, 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 diff --git a/streamrip/config.toml b/streamrip/config.toml index 623aeb98..dcf38b94 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -120,7 +120,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 @@ -181,10 +181,10 @@ 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 diff --git a/streamrip/converter.py b/streamrip/converter.py index 4215a0f2..d7e92e56 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -2,10 +2,12 @@ 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 @@ -60,7 +62,13 @@ def __init__( 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 @@ -150,9 +158,11 @@ def _embed_cover_art(self, cover_data: bytes, mime: str) -> None: 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 @@ -172,7 +182,7 @@ def _gen_command(self): if logger.getEffectiveLevel() != logging.DEBUG: command.extend(("-loglevel", "panic")) - command.extend(("-c:a", self.codec_lib)) + command.extend(("-c:a", self.get_codec_lib())) if self.show_progress: command.append("-stats") @@ -222,6 +232,10 @@ def _gen_command(self): return command + @classmethod + def get_codec_lib(cls) -> str: + return cls.codec_lib + def _is_command_valid(self): # TODO: add error handling for lossy codecs if self.ffmpeg_arg is not None and self.lossless: @@ -337,7 +351,11 @@ def get_quality_arg(cls, rate: int) -> str: 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`. @@ -350,11 +368,48 @@ class AAC(Converter): container = "m4a" default_ffmpeg_arg = "-b:a 256k" + @classmethod + def get_codec_lib(cls) -> 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_name = "aiff" + 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" + + def get_codec_lib(self) -> str: # type: ignore[override] + return self.codec_lib + + +@functools.cache +def _ffmpeg_has_encoder(name: str) -> bool: + try: + result = subprocess.run( + ["ffmpeg", "-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]: converter_classes = { "FLAC": FLAC, @@ -365,5 +420,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..12fda3a9 100644 --- a/streamrip/db.py +++ b/streamrip/db.py @@ -95,9 +95,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()} 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/album.py b/streamrip/media/album.py index 3ca79a29..d4d6cd16 100644 --- a/streamrip/media/album.py +++ b/streamrip/media/album.py @@ -7,7 +7,7 @@ from ..client import Client from ..config import Config from ..db import Database -from ..exceptions import NonStreamableError +from ..exceptions import NonStreamableError, TrackDownloadFailedError from ..filepath_utils import clean_filepath from ..metadata import AlbumMetadata from ..metadata.util import get_album_track_ids @@ -37,8 +37,12 @@ async def _resolve_and_download(pending: Pending): if track is None: return await track.rip() + except TrackDownloadFailedError: + pass # already logged and recorded by Track.download() except Exception as e: - logger.error(f"Error downloading track: {e}") + # Include the type: some exceptions have an empty message, + # which used to log as "Error downloading track: ''" (#938). + logger.error(f"Error downloading track: {type(e).__name__}: {e}") results = await asyncio.gather( *[_resolve_and_download(p) for p in self.tracks], return_exceptions=True diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index e1e7164d..122d5b39 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -15,7 +15,7 @@ from ..config import Config from ..console import console from ..db import Database -from ..exceptions import NonStreamableError +from ..exceptions import NonStreamableError, TrackDownloadFailedError from ..filepath_utils import clean_filename, clean_filepath from ..metadata import ( AlbumMetadata, @@ -126,8 +126,12 @@ async def _resolve_download(item: PendingPlaylistTrack): if track is None: return await track.rip() + except TrackDownloadFailedError: + pass # already logged and recorded by Track.download() except Exception as e: - logger.error(f"Error downloading track: {e}") + # Include the type: some exceptions have an empty message, + # which used to log as "Error downloading track: ''" (#938). + logger.error(f"Error downloading track: {type(e).__name__}: {e}") batches = self.batch( [_resolve_download(track) for track in self.tracks], @@ -248,7 +252,7 @@ def callback(): 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: @@ -365,7 +369,9 @@ async def fetch(session: aiohttp.ClientSession, url, **kwargs): **connector_kwargs, resolver=aiohttp.ThreadedResolver() ) - async with aiohttp.ClientSession(connector=connector) as session: + async with aiohttp.ClientSession( + connector=connector, trust_env=True + ) as session: page = await fetch(session, playlist_url) playlist_title_match = re_playlist_title_match.search(page) if playlist_title_match is None: diff --git a/streamrip/media/semaphore.py b/streamrip/media/semaphore.py index c71ea89c..3916d319 100644 --- a/streamrip/media/semaphore.py +++ b/streamrip/media/semaphore.py @@ -33,8 +33,8 @@ def global_download_semaphore(c: DownloadsConfig) -> asyncio.Semaphore | nullcon 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 02054256..bf5c41df 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -8,8 +8,9 @@ from ..config import Config from ..db import Database from ..exceptions import NonStreamableError, TrackDownloadFailedError -from ..filepath_utils import clean_filename +from ..filepath_utils import clean_filename, fit_filename from ..metadata import AlbumMetadata, Covers, TrackMetadata, tag_file +from ..metadata.tagger import TAGGABLE_EXTENSIONS from ..progress import add_title, get_progress_callback, remove_title from .artwork import download_artwork from .media import Media, Pending @@ -17,6 +18,9 @@ logger = logging.getLogger("streamrip") +# One try plus three retries with exponential backoff. +MAX_DOWNLOAD_ATTEMPTS = 4 + @dataclass(slots=True) class Track(Media): @@ -38,35 +42,39 @@ async def preprocess(self): add_title(self.meta.title) async def download(self): - # TODO: progress bar description 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}" + 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 @@ -86,19 +94,29 @@ async def postprocess(self): if self.is_single: remove_title(self.meta.title) - 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.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, @@ -108,6 +126,15 @@ async def _convert(self): ) 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 @@ -121,7 +148,7 @@ def _set_download_path(self): self.download_path = os.path.join( self.folder, - f"{track_path}.{self.downloadable.extension}", + fit_filename(track_path, self.downloadable.extension), ) diff --git a/streamrip/metadata/playlist.py b/streamrip/metadata/playlist.py index a4f74d5f..4cadc210 100644 --- a/streamrip/metadata/playlist.py +++ b/streamrip/metadata/playlist.py @@ -67,10 +67,12 @@ def from_qobuz(cls, resp: dict): ) 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}") + 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) diff --git a/streamrip/metadata/search_results.py b/streamrip/metadata/search_results.py index 4b3011b2..f618f191 100644 --- a/streamrip/metadata/search_results.py +++ b/streamrip/metadata/search_results.py @@ -268,7 +268,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 9903c6da..38dd8084 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 @@ -105,6 +106,7 @@ class Container(Enum): FLAC = 1 AAC = 2 MP3 = 3 + AIFF = 4 def get_mutagen_class(self, path: str): if self == Container.FLAC: @@ -116,18 +118,30 @@ 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]: if self == Container.FLAC: - return self._tag_flac(meta) - elif self == Container.MP3: - return self._tag_mp3(meta) + pairs = self._tag_flac(meta) + key_map = FLAC_KEY + elif self in (Container.MP3, Container.AIFF): + pairs = self._tag_mp3(meta) + key_map = {k: v.__name__ for k, v in MP3_KEY.items() if v is not None} elif self == Container.AAC: - return self._tag_mp4(meta) - # unreachable - return [] + pairs = self._tag_mp4(meta) + key_map = MP4_KEY + else: + return [] + # [metadata] exclude lists streamrip's own tag names ("genre", + # "albumartist", ...); it used to be ignored entirely (upstream #850). + excluded = {key_map[name] for name in exclude or () if key_map.get(name)} + return [(k, v) for k, v in pairs if k not in excluded] def _tag_flac(self, meta: TrackMetadata) -> list[tuple]: out = [] @@ -222,7 +236,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" @@ -240,24 +254,40 @@ def save_audio(self, audio, path): elif self == Container.AAC: audio.save() elif self == Container.MP3: - audio.save(path, "v2_version=3") + # 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): +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 4cc766ef..21fb1686 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -222,14 +222,18 @@ def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata | None: raise Exception 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/rip/cli.py b/streamrip/rip/cli.py index cc88e77b..d33c9cee 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -65,7 +65,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", @@ -152,7 +156,6 @@ def rip( 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: @@ -247,7 +250,7 @@ async def file(ctx, path): s = set(items) if len(s) < len(items): console.print( - f"Found [orange]{len(items)-len(s)}[/orange] repeated URLs!" + f"Found [orange]{len(items) - len(s)}[/orange] repeated URLs!" ) items = list(s) console.print( @@ -433,9 +436,7 @@ async def repair(ctx, yes, flat): console.print("[green]No failed downloads to repair!") return - console.print( - f"Found [yellow]{len(failed_items)}[/yellow] failed download(s)." - ) + 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 @@ -594,7 +595,7 @@ async def latest_streamrip_version(verify_ssl: bool = True) -> tuple[str, str | **connector_kwargs, resolver=aiohttp.ThreadedResolver() ) - async with aiohttp.ClientSession(connector=connector) as s: + async with aiohttp.ClientSession(connector=connector, trust_env=True) as s: async with s.get("https://pypi.org/pypi/streamrip/json") as resp: data = await resp.json() version = data["info"]["version"] diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 42f4d36e..0c5adb15 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -151,9 +151,7 @@ async def get_logged_in_client(self, source: str): prompter.save() else: try: - with console.status( - f"[cyan]Logging into {source}", spinner="dots" - ): + 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: @@ -206,7 +204,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: diff --git a/tests/test_deezer.py b/tests/test_deezer.py index b30372d6..e2129dcf 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -7,6 +7,7 @@ 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""" @@ -16,11 +17,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""" @@ -28,79 +30,84 @@ 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'): + 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 + 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'): + + 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 + 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'): + + 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 + def test_deezer_no_url_raises_instead_of_building_legacy_cdn_url(mock_deezer_client): """Unit test: a missing download URL fails immediately. @@ -120,10 +127,11 @@ def test_deezer_no_url_raises_instead_of_building_legacy_cdn_url(mock_deezer_cli mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = None - with patch.object(mock_deezer_client, 'get_session'): + with patch.object(mock_deezer_client, "get_session"): 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 = { @@ -138,12 +146,13 @@ def test_deezer_no_url_error_does_not_leak_a_cdn_url(mock_deezer_client): mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = None - with patch.object(mock_deezer_client, 'get_session'): + with patch.object(mock_deezer_client, "get_session"): 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. @@ -152,6 +161,7 @@ def test_deezer_no_url_follows_fallback_track(mock_deezer_client): 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 { @@ -169,11 +179,11 @@ def gw_get_track(track_id): } 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" + mock_deezer_client.client.get_track_url.side_effect = lambda token, fmt: ( + None if token == "token_123" else "https://test.flac" ) - with patch.object(mock_deezer_client, 'get_session'): + with patch.object(mock_deezer_client, "get_session"): downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) # The bytes are the fallback track's, so the id -- from which the Blowfish @@ -183,6 +193,7 @@ def gw_get_track(track_id): # 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. @@ -197,54 +208,65 @@ def test_deezer_fallback_is_not_followed_twice(mock_deezer_client): } mock_deezer_client.client.get_track_url.return_value = None - with patch.object(mock_deezer_client, 'get_session'): + with patch.object(mock_deezer_client, "get_session"): 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"): + 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)) + 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.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." ) @@ -252,12 +274,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_qobuz_errors.py b/tests/test_qobuz_errors.py index 9701b06d..6e448964 100644 --- a/tests/test_qobuz_errors.py +++ b/tests/test_qobuz_errors.py @@ -15,7 +15,9 @@ from streamrip.client.qobuz import QobuzClient from streamrip.exceptions import APIError, AuthenticationError -ALGOLIA = {"message": "Impossible to connect, please check your Algolia Application Id."} +ALGOLIA = { + "message": "Impossible to connect, please check your Algolia Application Id." +} TOKEN = "SECRET-TOKEN-abcdefghij" diff --git a/tests/test_reauth_prompt.py b/tests/test_reauth_prompt.py index 98038b6e..12364a82 100644 --- a/tests/test_reauth_prompt.py +++ b/tests/test_reauth_prompt.py @@ -35,13 +35,9 @@ def _main_with_expired_token(): 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("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) - ) + stack.enter_context(patch("streamrip.rip.main.Confirm.ask", return_value=confirm)) return stack diff --git a/tests/test_resumable_download.py b/tests/test_resumable_download.py new file mode 100644 index 00000000..d926412e --- /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: # noqa: BLE001 + 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: # noqa: BLE001 + 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" From c137eb024ff3978d2d09231a36bca5e777e068b7 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:00:23 +0000 Subject: [PATCH 41/62] fix(cli): fork-aware update check, clean Ctrl-C, config guards - The update check asked PyPI for upstream's release and told users to run `pip install streamrip --upgrade`, which would replace this fork with upstream. It now reads the latest release of Stensel8/streamrip (or the version on its default branch) and suggests installing from the fork - Versions are compared numerically (upstream PR #993), so an older release no longer shows up as an "update"; network/HTTP errors skip the check instead of crashing after a finished download (PR #995) - Ctrl-C cancels the run so Main.__aexit__ cleans up (__artwork dirs, sessions) instead of being ignored until downloads finish (PR #1025) - `rip -c` validates the codec with a click.Choice (adds OPUS and AIFF) - file/search/lastfm/id no longer crash when the config failed to load - Module-level logger, so helpers work when called outside `rip` Co-authored-by: berettavexee Co-authored-by: ghostdanser <5584730+sjbrownrigg@users.noreply.github.com> --- streamrip/rip/cli.py | 126 +++++++++++++++++++++++++++++++++---------- tests/test_cli.py | 38 +++++++++++++ 2 files changed, 136 insertions(+), 28 deletions(-) create mode 100644 tests/test_cli.py diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index d33c9cee..369197d2 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 @@ -22,11 +24,42 @@ from ..utils.ssl_utils import get_aiohttp_connector_kwargs 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 rip -- 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.") return wrapper @@ -197,14 +230,14 @@ async def url(ctx, urls): if version_coro is not None: latest_version, notes = await version_coro - if latest_version != __version__: + if is_newer_version(latest_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" + 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" ) - - console.print(Markdown(notes)) + if notes: + console.print(Markdown(notes)) except aiohttp.ClientConnectorCertificateError as e: from ..utils.ssl_utils import print_ssl_error_help @@ -228,6 +261,8 @@ async def file(ctx, path): rip file urls.txt """ + if ctx.obj["config"] is None: + return try: with ctx.obj["config"] as cfg: async with Main(cfg) as main: @@ -525,6 +560,8 @@ async def search(ctx, first, output_file, num_results, source, media_type, query rip 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 @@ -554,6 +591,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 @@ -573,6 +612,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) @@ -580,8 +621,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 @@ -589,26 +648,37 @@ 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, resolver=aiohttp.ThreadedResolver() - ) - - async with aiohttp.ClientSession(connector=connector, trust_env=True) 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: + connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=verify_ssl) + connector = aiohttp.TCPConnector( + **connector_kwargs, resolver=aiohttp.ThreadedResolver() + ) + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession( + connector=connector, trust_env=True, 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/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..0c3e77a2 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,38 @@ +from click.testing import CliRunner + +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 From 09f5c38f885772a70ebecdc84d15b2b4ee667d4d Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:01:44 +0000 Subject: [PATCH 42/62] style: make ruff check and ruff format pass on the whole repo - Move the http.client._MAXHEADERS tweak below the imports (E402) - Sort __all__ lists, fix import order and a None-first type union - Ignore ASYNC240 (os.path stat calls in async code are cheap) and, in tests only, synchronous fixture reads, explanatory commented code and mutable class attributes - Update the Qobuz spoofer test mock to aiohttp's ssl= argument (verify_ssl= is deprecated), as pointed out in review --- pyproject.toml | 8 +++++++- streamrip/__init__.py | 6 +++--- streamrip/client/__init__.py | 6 +++--- streamrip/media/__init__.py | 14 +++++++------- streamrip/media/semaphore.py | 2 +- streamrip/metadata/__init__.py | 20 ++++++++++---------- tests/test_deezer.py | 3 ++- tests/test_resumable_download.py | 4 ++-- tests/test_ssl_verification.py | 2 +- 9 files changed, 36 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4c5683f4..85bdd9b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,9 @@ target-version = "py310" [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"] @@ -90,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" diff --git a/streamrip/__init__.py b/streamrip/__init__.py index e249344c..daf6befa 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -1,10 +1,10 @@ import http.client -# Prevent failures on CDNs (like Qobuz/Akamai) that send >100 response headers -http.client._MAXHEADERS = 1000 - 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.3.0" diff --git a/streamrip/client/__init__.py b/streamrip/client/__init__.py index 4e5dd8cf..8fa610b3 100644 --- a/streamrip/client/__init__.py +++ b/streamrip/client/__init__.py @@ -6,11 +6,11 @@ from .tidal import TidalClient __all__ = [ + "BasicDownloadable", "Client", "DeezerClient", - "TidalClient", + "Downloadable", "QobuzClient", "SoundcloudClient", - "Downloadable", - "BasicDownloadable", + "TidalClient", ] 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/semaphore.py b/streamrip/media/semaphore.py index 3916d319..82e33f8f 100644 --- a/streamrip/media/semaphore.py +++ b/streamrip/media/semaphore.py @@ -4,7 +4,7 @@ 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: 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/tests/test_deezer.py b/tests/test_deezer.py index e2129dcf..674bd33f 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -1,6 +1,7 @@ import os -import pytest from unittest.mock import Mock, patch + +import pytest from util import arun from streamrip.client.deezer import DeezerClient diff --git a/tests/test_resumable_download.py b/tests/test_resumable_download.py index d926412e..f2439081 100644 --- a/tests/test_resumable_download.py +++ b/tests/test_resumable_download.py @@ -68,7 +68,7 @@ def do_GET(self): await fast_async_download(path, url, {}, lambda n: None, resume=attempt > 0) last_exc = None break - except Exception as e: # noqa: BLE001 + except Exception as e: last_exc = e srv.shutdown() @@ -119,7 +119,7 @@ def do_GET(self): try: await fast_async_download(path, url, {}, lambda n: None, resume=attempt > 0) break - except Exception: # noqa: BLE001 + except Exception: pass srv.shutdown() diff --git a/tests/test_ssl_verification.py b/tests/test_ssl_verification.py index b9800c27..2cbac6d9 100644 --- a/tests/test_ssl_verification.py +++ b/tests/test_ssl_verification.py @@ -222,7 +222,7 @@ async def test_qobuz_spoofer_initialization(mock_client_session): with patch( "streamrip.utils.ssl_utils.get_aiohttp_connector_kwargs" ) as mock_get_kwargs: - mock_get_kwargs.return_value = {"verify_ssl": True} + mock_get_kwargs.return_value = {"ssl": True} spoofer = QobuzSpoofer(verify_ssl=True) assert spoofer is not None From c9aa98c3cdc012b6c18afdbc62965c1ab164cd17 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:06:02 +0000 Subject: [PATCH 43/62] ci: Renovate + Dependabot, SHA-pinned workflows, Poetry 2 - CI: ruff lint/format, the test suite on Python 3.10-3.14, `poetry check --lock`, and a plain `pip install .` smoke test on Linux, Windows and macOS with the oldest and newest supported Python. Replaces the old pytest workflow, which installed Poetry 1.5.1 (it cannot read the PEP 621 metadata, as pointed out in review) and only ran on Python 3.10 - CodeQL now runs on dev instead of the non-existent main branch, on codeql-action v4 instead of the retired v1 - All actions pinned to commit SHAs; persist-credentials disabled - The PyPI publish workflow is replaced by one that attaches the wheel and sdist to GitHub releases: "streamrip" on PyPI belongs to upstream - Renovate (forkProcessing enabled): grouped updates for Python packages and GitHub Actions, digest pinning, weekly lock file maintenance, automerge for minor/patch once CI is green, majors wait for review - Dependabot: security updates only; version updates are Renovate's job so every release does not produce two PRs (raise open-pull-requests-limit to turn them on) - CODEOWNERS so Renovate assigns and requests review from @Stensel8 --- .github/CODEOWNERS | 1 + .github/dependabot.yml | 31 ++++++++++++ .github/workflows/ci.yml | 65 +++++++++++++++++++++++++ .github/workflows/codeql-analysis.yml | 69 +++++---------------------- .github/workflows/poetry-publish.yml | 23 --------- .github/workflows/pytest.yml | 41 ---------------- .github/workflows/release.yml | 28 +++++++++++ .github/workflows/ruff.yml | 11 ----- renovate.json | 65 +++++++++++++++++++++++++ 9 files changed, 203 insertions(+), 131 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/poetry-publish.yml delete mode 100644 .github/workflows/pytest.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/ruff.yml create mode 100644 renovate.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..6ed39bc0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Stensel8 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..93f6bdf2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +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 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - 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, oldest and + # newest supported Python. This is what broke on 3.14 (Pillow < 11). + install: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10", "3.14"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install . + - run: rip --version + - run: rip --help diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e602ad7c..7fc467be 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,24 +1,14 @@ -# 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" +name: CodeQL on: push: - branches: [ main ] + branches: [dev, main] pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] schedule: - - cron: '34 6 * * 4' + - cron: "34 6 * * 4" + +permissions: + contents: read jobs: analyze: @@ -28,44 +18,11 @@ jobs: 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 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: python + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 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/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 + } + ] +} From 80438441d19aaebfc6119bb8be6c1ec7ca4e603f Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:07:45 +0000 Subject: [PATCH 44/62] docs: install from the Stensel8 fork, document logins and changes - README: install commands point at github.com/Stensel8/streamrip (pip, pipx, uv), with a warning that PyPI, the AUR package and Homebrew install upstream streamrip; images and badges point at the fork; how to log in to Qobuz (token), Tidal (client choice) and Deezer; contribution notes - CHANGELOG.md lists what changed and which upstream issues and pull requests are addressed - Wiki links and the author/sponsor credit still point at nathom's upstream repository, which hosts that documentation --- CHANGELOG.md | 106 +++++++++++++++++++++++++++++++++++++++++++ README.md | 126 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 186 insertions(+), 46 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..8423d5bb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,106 @@ +# Changelog + +All notable changes in this fork of [nathom/streamrip](https://github.com/nathom/streamrip). Numbers refer to +upstream issues and pull requests. + +## 2.3.0 + +### Installation and dependencies + +- Installs on Python 3.10 through 3.14: 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)). +- Install from GitHub; the update notice points at this fork instead of telling + you to `pip install streamrip`, which would install upstream. + +### 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)); optional browser capture with `pip install 'streamrip[qobuz-login]'` + ([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)). + +### Tidal + +- Lossless tracks download as FLAC again instead of AAC 320 ([#966](https://github.com/nathom/streamrip/issues/966), + [PR #1017](https://github.com/nathom/streamrip/pull/1017)); optional hi-res client via `hires_client = true`, and hi-res tracks + served as MPEG-DASH are downloaded and remuxed ([PR #998](https://github.com/nathom/streamrip/pull/998)). Changing + the client asks for a new login once. +- 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. + +### 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. + +### 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. + +### Downloads, conversion and tagging + +- Failed downloads are no longer marked as downloaded, resolve failures are + recorded, and `rip repair` retries everything in the failed database + ([PR #1023](https://github.com/nathom/streamrip/pull/1023)). +- 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 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)); MP3s are written as ID3v2.3 as intended. +- 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)). + +### Development + +- CI on Python 3.10–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..eabe83ee 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,18 @@ -![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.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-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. -![downloading an album](https://github.com/nathom/streamrip/blob/dev/demo/download_album.png?raw=true) +> [!NOTE] +> This is a maintained fork of [nathom/streamrip](https://github.com/nathom/streamrip). +> It installs on Python 3.10 through 3.14 and fixes a large part of the open +> upstream issues and pull requests; see [CHANGELOG.md](CHANGELOG.md) for what +> changed and which upstream issues are addressed. + +![downloading an album](https://github.com/Stensel8/streamrip/blob/dev/demo/download_album.png?raw=true) ## Features @@ -13,58 +20,80 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. - 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 +- Has a database that stores the downloaded tracks' IDs so that repeats are avoided, and `rip repair` to retry the ones that failed +- Resumes interrupted downloads and retries with backoff - Concurrency and rate limiting - Interactive search for all sources - Highly customizable through the config file -- Integration with `youtube-dl` ## 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. +First, ensure [Python](https://www.python.org/downloads/) (3.10 to 3.14) and +[pip](https://pip.pypa.io/en/stable/installing/) are installed. Then install +[`ffmpeg`](https://ffmpeg.org/download.html). You may choose not to install it, +but conversion and Tidal hi-res downloads need it. + +Install streamrip from this repository: ```bash -pip3 install streamrip --upgrade +pip3 install --upgrade git+https://github.com/Stensel8/streamrip.git ``` -If you run into issues, try installing directly from the `dev` branch: +or, to keep it separate from other Python packages, with +[pipx](https://pipx.pypa.io/) or [uv](https://docs.astral.sh/uv/): ```bash -pip3 install git+https://github.com/nathom/streamrip.git@dev +pipx install git+https://github.com/Stensel8/streamrip.git +uv tool install git+https://github.com/Stensel8/streamrip.git ``` +To install a specific branch or release, add `@` to the URL, for example +`git+https://github.com/Stensel8/streamrip.git@dev`. + When you type ```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). +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) in the upstream wiki. -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 -``` +> [!IMPORTANT] +> `pip install streamrip` (PyPI), the AUR package and `brew install streamrip` +> all install **upstream** streamrip, not this fork. Upstream's release pins +> `Pillow<11`, which has no wheels for Python 3.14 and fails to build. -Alternatively, for users of Homebrew, you can install streamrip through brew. -``` -brew install streamrip -``` - -### Streamrip beta - -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 +### Optional extras ```bash -pip3 install git+https://github.com/nathom/streamrip.git@dev -``` +# Use certifi's CA bundle instead of the system certificates +pip3 install "streamrip[ssl] @ git+https://github.com/Stensel8/streamrip.git" + +# Let streamrip capture your Qobuz login token from a real browser +pip3 install "streamrip[qobuz-login] @ git+https://github.com/Stensel8/streamrip.git" +playwright install chromium +``` + +### Logging in + +- **Qobuz** moved its web login behind a captcha, so email/password login no + longer works for most accounts. When asked, enter your Qobuz **user id** and + **user_auth_token**: log in at [play.qobuz.com](https://play.qobuz.com/login), + open your browser's DevTools → Network, find the `user/login` request and + copy `user.id` and `user_auth_token` from its response. With the + `qobuz-login` extra installed, streamrip opens a browser and does this for you. + Accounts without a streaming subscription can download albums they bought. +- **Tidal** logs in through your browser (device login). Tidal decides per app + which formats it will stream; by default streamrip uses one that gets FLAC + 16/44.1 for every lossless release. Set `hires_client = true` in the `[tidal]` + section of the config to prefer 24-bit hi-res FLAC instead (ordinary lossless + releases then come as AAC). No developer account or client id is needed. +- **Deezer** needs the `arl` cookie of a logged-in session, see + [Finding your Deezer ARL cookie](https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie). + The download quality is limited to what your subscription allows. +- **SoundCloud** needs no login. + +If a saved login stops working, streamrip offers to log in again. ## Example Usage @@ -95,7 +124,7 @@ To set the maximum quality, use the `--quality` option to `0, 1, 2, 3, 4`: | 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) | +| 3 | 24 bit, ≤ 96 kHz | Tidal (hi-res FLAC), Qobuz, SoundCloud (rarely) | | 4 | 24 bit, ≤ 192 kHz | Qobuz | ```bash @@ -110,7 +139,7 @@ Search for playlists matching `rap` on Tidal rip search tidal playlist 'rap' ``` -![streamrip interactive search](https://github.com/nathom/streamrip/blob/dev/demo/playlist_search.png?raw=true) +![streamrip interactive search](https://github.com/Stensel8/streamrip/blob/dev/demo/playlist_search.png?raw=true) Search for *Rumours* on Tidal, and download it @@ -136,11 +165,11 @@ If you're confused about anything, see the help pages. The main help pages can b rip url --help ``` -![example_help_page.png](https://github.com/nathom/streamrip/blob/dev/demo/example_help_page.png?raw=true) +![example_help_page.png](https://github.com/Stensel8/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/). +For more in-depth information about `streamrip`, see the help pages and the [upstream wiki](https://github.com/nathom/streamrip/wiki/). ## Contributions @@ -149,20 +178,20 @@ 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. +Report problems with this fork in its [issue tracker](https://github.com/Stensel8/streamrip/issues) and +**use the Feature Request or Bug Report templates**, so all the information +needed to debug the issue is there. ### 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 +- Fork this repository and clone it +- `poetry install --all-extras` sets up a development environment +- `poetry run pytest` and `poetry run ruff check . && poetry run ruff format .` + should pass before you commit - Open a pull request to the `dev` branch -Please document any functions or obscure lines of code. +Please document any functions or obscure lines of code. Dependencies are kept up +to date by Renovate; Dependabot handles security updates. ### The Wiki @@ -171,6 +200,10 @@ Nothing is too obvious and everything is appreciated. ## Acknowledgements +streamrip was written by [nathom](https://github.com/nathom); this fork builds +on the work of everyone who sent fixes upstream, whose pull requests are +credited in the commit history and [CHANGELOG.md](CHANGELOG.md). + 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: @@ -186,4 +219,5 @@ I will not be responsible for how **you** use `streamrip`. By using `streamrip`, ## Sponsorship -Consider becoming a Github sponsor for me if you enjoy my open source software. +Consider [sponsoring nathom](https://github.com/sponsors/nathom), the original +author of streamrip, if you enjoy it. From b3b72d9a51bf6318263961718926a2d3a9939f94 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:09:47 +0000 Subject: [PATCH 45/62] fix(qobuz): keep credentials and the app secret out of debug logs CodeQL flagged three clear-text logging paths: - the login params (masked, but still derived from the password) - every API request's params, which carry user_auth_token - the raw track/getFileUrl signature preimage, which contains the app secret in plain text Log the endpoint and the login method only. The request signature moves into file_url_signature(); its MD5 is mandated by the Qobuz API (it is a request signature, not credential storage) and is annotated for CodeQL. Tests compare against that helper plus a known-answer value instead of hashing the secret themselves. The advanced CodeQL workflow is removed: the repository has CodeQL default setup enabled, which already analyses every push and PR and rejects results from advanced configurations. Also ignore .venv/. --- .github/workflows/codeql-analysis.yml | 28 ------------------ .gitignore | 1 + streamrip/client/qobuz.py | 42 ++++++++++++++++----------- tests/test_qobuz_download_only.py | 25 +++++++--------- 4 files changed, 37 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 7fc467be..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: CodeQL - -on: - push: - branches: [dev, main] - pull_request: - schedule: - - cron: "34 6 * * 4" - -permissions: - contents: read - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 - with: - languages: python - - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 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/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 2bb91c20..86b87541 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -23,19 +23,24 @@ logger = logging.getLogger("streamrip") -# Qobuz takes credentials as URL query parameters, so they must be masked -# anywhere params reach a log line or an exception message. -_SENSITIVE_PARAMS = ("user_auth_token", "password", "email", "user_id", "request_sig") +QOBUZ_BASE_URL = "https://www.qobuz.com/api.json/0.2" -def _redacted(params: dict) -> dict: - """params with credentials masked, for logs and error messages.""" - return { - k: ("" if k in _SENSITIVE_PARAMS else v) for k, v in params.items() - } +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() -QOBUZ_BASE_URL = "https://www.qobuz.com/api.json/0.2" QOBUZ_FEATURED_KEYS = { "most-streamed", @@ -240,7 +245,10 @@ async def _attempt_login(self): "app_id": str(c.app_id), } - logger.debug("Request params %s", _redacted(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) # The response carries the user_auth_token and the account profile. logger.debug("Login response keys: %s", sorted(resp)) @@ -257,7 +265,7 @@ async def _attempt_login(self): "with a user id and user_auth_token instead." ) elif status == 400: - raise InvalidAppIdError(f"Invalid app id from params {_redacted(params)}") + raise InvalidAppIdError(f"Qobuz rejected app id {c.app_id}") elif status != 200: raise APIError( f"Qobuz login failed (HTTP {status}): " @@ -582,13 +590,11 @@ async def _request_file_url( # 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" - r_sig = f"trackgetFileUrlformat_id{quality}intent{intent}track_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) 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": intent, @@ -600,7 +606,9 @@ 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, _redacted(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 ""): diff --git a/tests/test_qobuz_download_only.py b/tests/test_qobuz_download_only.py index bf0fba51..7ca34455 100644 --- a/tests/test_qobuz_download_only.py +++ b/tests/test_qobuz_download_only.py @@ -14,12 +14,11 @@ All network calls are mocked; these run without Qobuz credentials. """ -import hashlib from unittest.mock import AsyncMock from util import arun -from streamrip.client.qobuz import QobuzClient +from streamrip.client.qobuz import QobuzClient, file_url_signature from streamrip.config import Config @@ -119,13 +118,8 @@ async def fake_api(epoint, params): assert params["intent"] == "download" # 2. the signed preimage used intent=download too (reconstruct + md5 match) format_id = QobuzClient.get_quality(quality) - expected_preimage = ( - f"trackgetFileUrlformat_id{format_id}intentdownload" - f"track_id{track_id}{params['request_ts']}{secret}" - ) - assert ( - hashlib.md5(expected_preimage.encode("utf-8")).hexdigest() - == params["request_sig"] + assert params["request_sig"] == file_url_signature( + format_id, "download", track_id, params["request_ts"], secret ) @@ -148,11 +142,14 @@ async def fake_api(epoint, params): params = captured["params"] assert params["intent"] == "stream" format_id = QobuzClient.get_quality(quality) - expected_preimage = ( - f"trackgetFileUrlformat_id{format_id}intentstream" - f"track_id{track_id}{params['request_ts']}{secret}" + 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 ( - hashlib.md5(expected_preimage.encode("utf-8")).hexdigest() - == params["request_sig"] + file_url_signature(27, "stream", "19512574", 1700000000.5, "secret") + == "7e51324fbe3fb7a052f218264ed520f1" ) From 709a8d065504d1b86e30c7b6c9878890a7d0c802 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:42:39 +0200 Subject: [PATCH 46/62] build: drop Python <3.14 support This fork now targets Python 3.14 only instead of 3.10-3.14. - requires-python = ">=3.14,<4.0"; classifiers and ruff target-version (py314) updated to match - CI: the test and install matrices no longer iterate Python versions, just 3.14 - poetry.lock regenerated: tomli, exceptiongroup, backports-asyncio-runner, backports-datetime-fromisoformat and async-timeout drop out, since those only existed to backport stdlib features streamrip's dependencies now get natively on 3.14 - README/CHANGELOG wording updated to match - ruff's py314 target formats multi-exception catches without parens (PEP 758), applied across tidal.py, converter.py, cli.py and a test --- .github/workflows/ci.yml | 12 +-- CHANGELOG.md | 4 +- README.md | 6 +- poetry.lock | 186 ++------------------------------- pyproject.toml | 8 +- streamrip/client/tidal.py | 4 +- streamrip/converter.py | 2 +- streamrip/rip/cli.py | 4 +- tests/test_ssl_verification.py | 4 +- 9 files changed, 23 insertions(+), 207 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93f6bdf2..3fcfebcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,17 +24,13 @@ jobs: test: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: ${{ matrix.python-version }} + python-version: "3.14" - name: Install Poetry run: python -m pip install "poetry>=2.1,<3" - name: Check that poetry.lock matches pyproject.toml @@ -44,22 +40,20 @@ jobs: - name: Run tests run: poetry run pytest - # Installing the way users do: plain pip, on every platform, oldest and - # newest supported Python. This is what broke on 3.14 (Pillow < 11). + # 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] - python-version: ["3.10", "3.14"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: ${{ matrix.python-version }} + python-version: "3.14" - run: python -m pip install . - run: rip --version - run: rip --help diff --git a/CHANGELOG.md b/CHANGELOG.md index 8423d5bb..de018b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ upstream issues and pull requests. ### Installation and dependencies -- Installs on Python 3.10 through 3.14: every dependency is on a current +- 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). @@ -101,6 +101,6 @@ upstream issues and pull requests. ### Development -- CI on Python 3.10–3.14, install smoke test on Linux, Windows and macOS, +- 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 eabe83ee..4beb00c7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ ![streamrip logo](https://github.com/Stensel8/streamrip/blob/dev/demo/logo.svg?raw=true) [![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.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-blue)](https://github.com/Stensel8/streamrip/blob/dev/pyproject.toml) +[![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. > [!NOTE] > This is a maintained fork of [nathom/streamrip](https://github.com/nathom/streamrip). -> It installs on Python 3.10 through 3.14 and fixes a large part of the open +> It targets current Python (3.14+) and fixes a large part of the open > upstream issues and pull requests; see [CHANGELOG.md](CHANGELOG.md) for what > changed and which upstream issues are addressed. @@ -28,7 +28,7 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. ## Installation -First, ensure [Python](https://www.python.org/downloads/) (3.10 to 3.14) and +First, ensure [Python](https://www.python.org/downloads/) 3.14 or newer and [pip](https://pip.pypa.io/en/stable/installing/) are installed. Then install [`ffmpeg`](https://ffmpeg.org/download.html). You may choose not to install it, but conversion and Tidal hi-res downloads need it. diff --git a/poetry.lock b/poetry.lock index d7489f92..9c0d3231 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -156,12 +156,10 @@ files = [ [package.dependencies] aiohappyeyeballs = ">=2.5.0" aiosignal = ">=1.4.0" -async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" propcache = ">=0.2.0" -typing_extensions = {version = ">=4.4", markers = "python_version < \"3.13\""} yarl = ">=1.17.0,<2.0" [package.extras] @@ -193,20 +191,6 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" -typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} - -[[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" @@ -220,77 +204,6 @@ files = [ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -optional = false -python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, - {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, -] - -[[package]] -name = "backports-datetime-fromisoformat" -version = "2.0.3" -description = "Backport of Python 3.11's datetime.fromisoformat" -optional = false -python-versions = ">3" -groups = ["main"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, - {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -543,25 +456,6 @@ files = [ [package.dependencies] requests = "*" -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "frozenlist" version = "1.8.0" @@ -835,9 +729,6 @@ files = [ {file = "m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd"}, ] -[package.dependencies] -backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1055,9 +946,6 @@ files = [ {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.48.1" @@ -1509,12 +1397,10 @@ files = [ [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -1532,9 +1418,7 @@ files = [ ] [package.dependencies] -backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} pytest = ">=8.4,<10" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] @@ -1640,64 +1524,6 @@ files = [ {file = "simple_term_menu-1.6.6.tar.gz", hash = "sha256:9813d36f5749d62d200a5599b1ec88469c71378312adc084c00c00bfbb383893"}, ] -[[package]] -name = "tomli" -version = "2.4.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, - {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, - {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, - {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, - {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, - {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, - {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, - {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, - {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, - {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, - {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, - {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, - {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, - {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, - {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, - {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, - {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, - {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, - {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, - {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, - {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, - {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, - {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, - {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, - {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, - {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, - {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, - {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, - {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, - {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, - {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, - {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, - {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, - {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, - {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, - {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, - {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, - {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, - {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, - {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, - {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, - {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, - {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, - {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, - {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, - {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, - {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, -] - [[package]] name = "tomlkit" version = "0.15.1" @@ -1714,14 +1540,14 @@ files = [ name = "typing-extensions" version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false +optional = true python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "extra == \"qobuz-login\"" files = [ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] -markers = {main = "python_version < \"3.13\" or extra == \"qobuz-login\"", dev = "python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1929,5 +1755,5 @@ ssl = ["certifi"] [metadata] lock-version = "2.1" -python-versions = ">=3.10,<4.0" -content-hash = "0060b1f3b9a9d8c2ef4c8365120af7d54ea1a36a26d48d88d802c62e7ccbbd38" +python-versions = ">=3.14,<4.0" +content-hash = "dbec9a7070e5a1d575928f190863bd3b6c3e4a01f06b8112330756c0ebd47b9a" diff --git a/pyproject.toml b/pyproject.toml index 85bdd9b9..839e52b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,15 +6,11 @@ authors = [{ name = "nathom", email = "nathanthomas707@gmail.com" }] maintainers = [{ name = "Stensel8" }] license = "GPL-3.0-only" readme = "README.md" -requires-python = ">=3.10,<4.0" +requires-python = ">=3.14,<4.0" keywords = ["hi-res", "free", "music", "download"] classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] dependencies = [ @@ -76,7 +72,7 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.ruff] -target-version = "py310" +target-version = "py314" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index b8b4aa65..48d9be23 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -108,7 +108,7 @@ async def login(self): try: self.token_expiry = float(c.token_expiry) - except (TypeError, ValueError): + except TypeError, ValueError: self.token_expiry = 0.0 self.refresh_token = c.refresh_token @@ -241,7 +241,7 @@ async def get_downloadable(self, track_id: str, quality: int): try: manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) - except (JSONDecodeError, UnicodeDecodeError, ValueError): + except JSONDecodeError, UnicodeDecodeError, ValueError: if quality <= 0: raise NonStreamableError(f"Could not read manifest for {track_id}") logger.warning( diff --git a/streamrip/converter.py b/streamrip/converter.py index d7e92e56..e6c64c47 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -405,7 +405,7 @@ def _ffmpeg_has_encoder(name: str) -> bool: timeout=10, stdin=subprocess.DEVNULL, ) - except (OSError, subprocess.SubprocessError): + except OSError, subprocess.SubprocessError: return False return name in result.stdout diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 369197d2..8620c46b 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -51,14 +51,14 @@ def stop(): try: loop.add_signal_handler(signal.SIGINT, stop) - except (NotImplementedError, RuntimeError): + except NotImplementedError, RuntimeError: pass # Windows: default KeyboardInterrupt behaviour return await f(*args, **kwargs) try: return asyncio.run(run()) - except (asyncio.CancelledError, KeyboardInterrupt): + except asyncio.CancelledError, KeyboardInterrupt: console.print("[yellow]Stopped.") return wrapper diff --git a/tests/test_ssl_verification.py b/tests/test_ssl_verification.py index 2cbac6d9..4827f00e 100644 --- a/tests/test_ssl_verification.py +++ b/tests/test_ssl_verification.py @@ -279,7 +279,7 @@ async def test_lastfm_playlist_session_creation(mock_client_session): # Try to parse the playlist with pytest.raises(Exception): await pending_playlist._parse_lastfm_playlist() - except (AttributeError, TypeError): + except AttributeError, TypeError: pytest.skip( "verify_ssl not used in PendingLastfmPlaylist._parse_lastfm_playlist yet" ) @@ -313,7 +313,7 @@ async def test_client_uses_config_settings(): call_kwargs = mock_get_session.call_args.kwargs assert "verify_ssl" in call_kwargs assert call_kwargs["verify_ssl"] is False - except (AttributeError, AssertionError): + 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}") From 0e816a0abfc6a14a6b466da60927709fca2654b2 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:44:26 +0200 Subject: [PATCH 47/62] Delete .github/CODEOWNERS --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 6ed39bc0..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @Stensel8 From 50a5594e800423f093a8c8a45c5be2e487158885 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:45:49 +0200 Subject: [PATCH 48/62] docs: cite issues already resolved by the existing rework Went through the highest-signal still-open upstream issues and checked each against the current code rather than the title alone: - #974: get_downloadable already branches on manifestMimeType == application/dash+xml before the BTS-JSON parse, so HI_RES_LOSSLESS no longer JSONDecodeErrors into a silent AAC downgrade - #897, #968: both fail against the old hardcoded Tidal client id; moot now that PR #1017's id is the default - #677, #701: get_downloadable clamps into size_map with an explicit quality > 0 guard instead of max() over a list that can be all zeros, and remove_artwork_tempdirs already tolerates a dir that was never created by a track that failed partway through - #848, #633: SoundCloud's HLS path downloads segments concurrently but reassembles them via an index-keyed dict, so out-of-order completion cannot reorder the audio; progressive is also preferred over HLS whenever it exists None of these needed a code change, just linking them to the fix that already covers them. --- CHANGELOG.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de018b95..0e98a37c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,10 @@ upstream issues and pull requests. ### 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)); optional hi-res client via `hires_client = true`, and hi-res tracks - served as MPEG-DASH are downloaded and remuxed ([PR #998](https://github.com/nathom/streamrip/pull/998)). Changing + 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)). Changing the client asks for a new login once. - 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)). @@ -76,12 +78,18 @@ upstream issues and pull requests. - 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)). ### Downloads, conversion and tagging - Failed downloads are no longer marked as downloaded, resolve failures are recorded, and `rip repair` retries everything in the failed database - ([PR #1023](https://github.com/nathom/streamrip/pull/1023)). + ([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)). From eb91ac04bd13c75837ff8a4b8e06f8dc8f8ee6a1 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:00:07 +0200 Subject: [PATCH 49/62] docs: add a plain venv option to the install instructions pipx/uv aren't always available; a stdlib venv works everywhere and was missing entirely, including the fish activation script (venvs also generate activate.fish, not just activate for bash/zsh). --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 4beb00c7..419824c5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,32 @@ pipx install git+https://github.com/Stensel8/streamrip.git uv tool install git+https://github.com/Stensel8/streamrip.git ``` +or in a plain virtual environment, which also works if you don't have pipx or +uv and want `rip` and its dependencies kept out of your system Python: + +```bash +# bash/zsh +python3 -m venv .venv +source .venv/bin/activate +``` + +```fish +# fish +python3 -m venv .venv +source .venv/bin/activate.fish +``` + +then, with the venv active (your prompt shows `(.venv)`), install into it the +same way as above: + +```bash +pip install --upgrade git+https://github.com/Stensel8/streamrip.git +``` + +`rip` is on `PATH` for as long as that venv is active. Leave it with +`deactivate`; come back to it later from the project directory with the same +`source` command, no need to recreate it. + To install a specific branch or release, add `@` to the URL, for example `git+https://github.com/Stensel8/streamrip.git@dev`. From be63d84136b6a59d4e9503ba2c6d66c0a3f329e8 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:15:24 +0200 Subject: [PATCH 50/62] Merge pull request #3 from Stensel8/docs/venv-first-install Installation: venv-first docs, bundled ffmpeg extra, one-step install scripts --- README.md | 77 +++++++++++++++++++++++--------- install.ps1 | 27 +++++++++++ install.sh | 30 +++++++++++++ poetry.lock | 21 ++++++++- pyproject.toml | 2 + streamrip/client/downloadable.py | 18 +++++--- streamrip/converter.py | 14 ++++-- streamrip/utils/ffmpeg_utils.py | 23 ++++++++++ tests/test_ffmpeg_utils.py | 36 +++++++++++++++ 9 files changed, 216 insertions(+), 32 deletions(-) create mode 100644 install.ps1 create mode 100755 install.sh create mode 100644 streamrip/utils/ffmpeg_utils.py create mode 100644 tests/test_ffmpeg_utils.py diff --git a/README.md b/README.md index 419824c5..181d8984 100644 --- a/README.md +++ b/README.md @@ -28,27 +28,33 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. ## Installation -First, ensure [Python](https://www.python.org/downloads/) 3.14 or newer and -[pip](https://pip.pypa.io/en/stable/installing/) are installed. Then install -[`ffmpeg`](https://ffmpeg.org/download.html). You may choose not to install it, -but conversion and Tidal hi-res downloads need it. - -Install streamrip from this repository: +The fastest way to get going: run the install script for your OS. It fetches +Python 3.14 and ffmpeg for you (via [uv](https://docs.astral.sh/uv/)) and +installs streamrip into a `.venv` in the current directory: ```bash -pip3 install --upgrade git+https://github.com/Stensel8/streamrip.git +# Linux/macOS +curl -LsSf https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.sh | bash ``` -or, to keep it separate from other Python packages, with -[pipx](https://pipx.pypa.io/) or [uv](https://docs.astral.sh/uv/): - -```bash -pipx install git+https://github.com/Stensel8/streamrip.git -uv tool install git+https://github.com/Stensel8/streamrip.git +```powershell +# Windows (PowerShell) +irm https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.ps1 | iex ``` -or in a plain virtual environment, which also works if you don't have pipx or -uv and want `rip` and its dependencies kept out of your system Python: +Then activate that venv and run `rip`, same as the manual steps below. Read +on if you'd rather do each step yourself, or already have Python and ffmpeg +set up. + +First, ensure [Python](https://www.python.org/downloads/) 3.14 or newer and +[pip](https://pip.pypa.io/en/stable/installing/) are installed. You'll also +need `ffmpeg` for conversion and Tidal hi-res downloads: either install it +yourself (e.g. `apt install ffmpeg`, `brew install ffmpeg`, +[ffmpeg.org](https://ffmpeg.org/download.html) for Windows), or skip that and +add the `ffmpeg` extra below to get a working one bundled with streamrip. + +Always install Python packages into a virtual environment, never into your +system Python. Create one and activate it: ```bash # bash/zsh @@ -62,16 +68,25 @@ python3 -m venv .venv source .venv/bin/activate.fish ``` -then, with the venv active (your prompt shows `(.venv)`), install into it the -same way as above: +Then, with the venv active (your prompt shows `(.venv)`), install streamrip +from this repository into it: ```bash pip install --upgrade git+https://github.com/Stensel8/streamrip.git ``` -`rip` is on `PATH` for as long as that venv is active. Leave it with +`rip` is on `PATH` for as long as that venv stays active. Leave it with `deactivate`; come back to it later from the project directory with the same -`source` command, no need to recreate it. +`source` command, no need to recreate the venv. + +[pipx](https://pipx.pypa.io/) and [uv](https://docs.astral.sh/uv/) do the same +per-tool venv isolation without the manual `activate` step, if you have either +installed: + +```bash +pipx install git+https://github.com/Stensel8/streamrip.git +uv tool install git+https://github.com/Stensel8/streamrip.git +``` To install a specific branch or release, add `@` to the URL, for example `git+https://github.com/Stensel8/streamrip.git@dev`. @@ -84,6 +99,13 @@ 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) in the upstream wiki. +> [!TIP] +> If `rip` runs something else entirely (not a streamrip help page), a shell +> alias or function named `rip` is shadowing it — aliases and functions are +> checked before `PATH`, venv or no venv. Run `command -v rip` (or, in fish, +> `type rip`) to see what it actually resolves to. `command rip` bypasses the +> alias/function and runs the real one from the active venv. + > [!IMPORTANT] > `pip install streamrip` (PyPI), the AUR package and `brew install streamrip` > all install **upstream** streamrip, not this fork. Upstream's release pins @@ -91,15 +113,28 @@ it should show the main help page. If you have no idea what these mean, or are h ### Optional extras +Add the extra in brackets to the install command for whichever method you +used above. With the venv method: + ```bash +# Bundle a working ffmpeg, so there's no separate OS-level install +pip install "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" + # Use certifi's CA bundle instead of the system certificates -pip3 install "streamrip[ssl] @ git+https://github.com/Stensel8/streamrip.git" +pip install "streamrip[ssl] @ git+https://github.com/Stensel8/streamrip.git" # Let streamrip capture your Qobuz login token from a real browser -pip3 install "streamrip[qobuz-login] @ git+https://github.com/Stensel8/streamrip.git" +pip install "streamrip[qobuz-login] @ git+https://github.com/Stensel8/streamrip.git" playwright install chromium ``` +pipx and uv take the same `package[extra] @ url` spec, for example: + +```bash +pipx install "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" +uv tool install "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" +``` + ### Logging in - **Qobuz** moved its web login behind a captcha, so email/password login no diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..659e176c --- /dev/null +++ b/install.ps1 @@ -0,0 +1,27 @@ +# 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[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" + +Write-Host "" +Write-Host "Done. Activate the venv, then run rip:" +Write-Host " .venv\Scripts\Activate.ps1" +Write-Host " rip --help" diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..e0108ee8 --- /dev/null +++ b/install.sh @@ -0,0 +1,30 @@ +#!/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[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" + +echo +echo "Done. Activate the venv, then run rip:" +echo " source .venv/bin/activate # bash/zsh" +echo " source .venv/bin/activate.fish # fish" +echo " rip --help" diff --git a/poetry.lock b/poetry.lock index 9c0d3231..b54add48 100644 --- a/poetry.lock +++ b/poetry.lock @@ -705,6 +705,24 @@ files = [ [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 = "imageio-ffmpeg" +version = "0.6.0" +description = "FFMPEG wrapper for Python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"ffmpeg\"" +files = [ + {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 = "iniconfig" version = "2.3.0" @@ -1750,10 +1768,11 @@ multidict = ">=4.0" propcache = ">=0.2.1" [extras] +ffmpeg = ["imageio-ffmpeg"] qobuz-login = ["playwright"] ssl = ["certifi"] [metadata] lock-version = "2.1" python-versions = ">=3.14,<4.0" -content-hash = "dbec9a7070e5a1d575928f190863bd3b6c3e4a01f06b8112330756c0ebd47b9a" +content-hash = "b095cb652034ebc3bbad736d8fc55b994b5e63b3182c845af4e730f2dfd4140a" diff --git a/pyproject.toml b/pyproject.toml index 839e52b2..7338b25b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,8 @@ dependencies = [ ssl = ["certifi>=2024.7.4"] # Capture the Qobuz user_auth_token from a real browser login. qobuz-login = ["playwright>=1.49"] +# A prebuilt ffmpeg, for when there's no system ffmpeg (or none on PATH). +ffmpeg = ["imageio-ffmpeg>=0.5"] [project.scripts] rip = "streamrip.rip:rip" diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index c006ad84..2ff384ea 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -24,6 +24,7 @@ from .. import converter from ..exceptions import NonStreamableError +from ..utils.ffmpeg_utils import find_ffmpeg logger = logging.getLogger("streamrip") @@ -429,9 +430,11 @@ async def content_length(url: str) -> int | None: return self._size async def _download(self, path: str, callback): - if shutil.which("ffmpeg") is None: + ffmpeg_path = find_ffmpeg() + if ffmpeg_path is None: raise NonStreamableError( - "FFmpeg is required to download Tidal hi-res (DASH) tracks." + "FFmpeg is required to download Tidal hi-res (DASH) tracks. " + "Install it, or install streamrip's ffmpeg extra." ) tmp_path = path + ".dash.mp4" try: @@ -444,7 +447,7 @@ async def _download(self, path: str, callback): callback(len(chunk)) proc = await asyncio.create_subprocess_exec( - "ffmpeg", + ffmpeg_path, "-i", tmp_path, "-c", @@ -546,8 +549,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: @@ -576,7 +582,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", diff --git a/streamrip/converter.py b/streamrip/converter.py index e6c64c47..f358cccb 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -12,6 +12,7 @@ from typing import Final, Optional from .exceptions import ConversionError +from .utils.ffmpeg_utils import find_ffmpeg logger = logging.getLogger("streamrip") @@ -55,9 +56,11 @@ def __init__( :param remove_source: Remove the source file after conversion. :type remove_source: bool """ - 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 @@ -174,7 +177,7 @@ def _embed_cover_art(self, cover_data: bytes, mime: str) -> None: def _gen_command(self): command = [ - "ffmpeg", + self.ffmpeg_path, "-i", self.filename, ] @@ -397,9 +400,12 @@ def get_codec_lib(self) -> str: # type: ignore[override] @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", "-hide_banner", "-encoders"], + [ffmpeg_path, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=10, 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/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 From 689f6f77ff7664c023e5f29a29dc6c1e9c896e92 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:22:47 +0200 Subject: [PATCH 51/62] fix(install): verify the install and warn about alias shadowing (#4) A real run exposed this: after ./install.sh finished, bare 'rip --help' (no activation) hit a pre-existing shell function named rip and ran that instead, with no indication anything was wrong. Have both scripts verify the venv's own rip actually works right after installing (fail loudly if not), and print the exact symptom and fix for shadowing: use 'command -v rip' / 'Get-Command rip -All' to see what's actually claiming the name, and that .venv/bin/rip (or .venv\Scripts\rip.exe) always works regardless of activation or shadowing. --- install.ps1 | 15 +++++++++++++-- install.sh | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 659e176c..5271d550 100644 --- a/install.ps1 +++ b/install.ps1 @@ -22,6 +22,17 @@ uv pip install --python .venv --upgrade ` "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" Write-Host "" -Write-Host "Done. Activate the venv, then run rip:" +$version = & .venv\Scripts\rip.exe --version 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Host "Done. $version is installed at .venv\Scripts\rip.exe." +} else { + Write-Host "Install finished, but .venv\Scripts\rip.exe --version failed -- something's wrong." + exit 1 +} +Write-Host "" +Write-Host "Activate the venv so plain 'rip' works, then use it normally:" Write-Host " .venv\Scripts\Activate.ps1" -Write-Host " rip --help" +Write-Host "" +Write-Host "If 'rip' then runs something else, an alias or function named rip" +Write-Host "is shadowing it (check with 'Get-Command rip -All')." +Write-Host ".venv\Scripts\rip.exe always works regardless of that, activated or not." diff --git a/install.sh b/install.sh index e0108ee8..3147d842 100755 --- a/install.sh +++ b/install.sh @@ -24,7 +24,17 @@ uv pip install --python .venv --upgrade \ "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" echo -echo "Done. Activate the venv, then run rip:" +if .venv/bin/rip --version >/dev/null 2>&1; then + echo "Done. $(.venv/bin/rip --version) is installed at .venv/bin/rip." +else + echo "Install finished, but .venv/bin/rip --version failed -- something's wrong." + exit 1 +fi +echo +echo "Activate the venv so plain 'rip' works, then use it normally:" echo " source .venv/bin/activate # bash/zsh" echo " source .venv/bin/activate.fish # fish" -echo " rip --help" +echo +echo "If 'rip' then runs something else, a shell alias or function named rip" +echo "is shadowing it (check with 'command -v rip', or 'type rip' in fish)." +echo ".venv/bin/rip always works regardless of that, activated or not." From 1bc52ed186c96b78993425245cf391f754e29670 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:49:55 +0200 Subject: [PATCH 52/62] rename: CLI command is streamrip, not rip (#5) * rename: CLI command is streamrip, not rip --- .github/ISSUE_TEMPLATE/bug_report.yml | 10 +++---- .github/workflows/ci.yml | 4 +-- CHANGELOG.md | 4 ++- README.md | 39 ++++++++++++++------------- install.ps1 | 14 +++++----- install.sh | 15 ++++++----- pyproject.toml | 2 +- streamrip/client/deezer.py | 2 +- streamrip/client/qobuz.py | 2 +- streamrip/config.py | 4 +-- streamrip/config.toml | 6 ++--- streamrip/media/track.py | 6 ++--- streamrip/metadata/album.py | 2 +- streamrip/rip/cli.py | 10 +++---- streamrip/rip/main.py | 4 +-- streamrip/utils/ssl_utils.py | 2 +- tests/test_config.toml | 6 ++--- tests/test_config_old.toml | 6 ++--- tests/test_deezer_client.py | 2 +- tests/test_qobuz_errors.py | 2 +- tests/test_reauth_prompt.py | 2 +- 21 files changed, 74 insertions(+), 70 deletions(-) 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/workflows/ci.yml b/.github/workflows/ci.yml index 3fcfebcb..6a676aa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,5 +55,5 @@ jobs: with: python-version: "3.14" - run: python -m pip install . - - run: rip --version - - run: rip --help + - run: streamrip --version + - run: streamrip --help diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e98a37c..4d61957e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ upstream issues and pull requests. - HTTP(S)_PROXY / ALL_PROXY environment variables are honoured ([#961](https://github.com/nathom/streamrip/issues/961)). - 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 @@ -85,7 +87,7 @@ upstream issues and pull requests. ### Downloads, conversion and tagging - Failed downloads are no longer marked as downloaded, resolve failures are - recorded, and `rip repair` retries everything in the failed database + recorded, 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 diff --git a/README.md b/README.md index 181d8984..8c901556 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. - 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, and `rip repair` to retry the ones that failed +- Has a database that stores the downloaded tracks' IDs so that repeats are avoided, and `streamrip repair` to retry the ones that failed - Resumes interrupted downloads and retries with backoff - Concurrency and rate limiting - Interactive search for all sources @@ -42,7 +42,7 @@ curl -LsSf https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.sh | irm https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.ps1 | iex ``` -Then activate that venv and run `rip`, same as the manual steps below. Read +Then activate that venv and run `streamrip`, same as the manual steps below. Read on if you'd rather do each step yourself, or already have Python and ffmpeg set up. @@ -75,7 +75,7 @@ from this repository into it: pip install --upgrade git+https://github.com/Stensel8/streamrip.git ``` -`rip` is on `PATH` for as long as that venv stays active. Leave it with +`streamrip` is on `PATH` for as long as that venv stays active. Leave it with `deactivate`; come back to it later from the project directory with the same `source` command, no need to recreate the venv. @@ -94,17 +94,18 @@ To install a specific branch or release, add `@` to the URL, for When you type ```bash -rip +streamrip ``` 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) in the upstream wiki. > [!TIP] -> If `rip` runs something else entirely (not a streamrip help page), a shell -> alias or function named `rip` is shadowing it — aliases and functions are -> checked before `PATH`, venv or no venv. Run `command -v rip` (or, in fish, -> `type rip`) to see what it actually resolves to. `command rip` bypasses the -> alias/function and runs the real one from the active venv. +> If `streamrip` runs something else entirely (not a streamrip help page), a +> shell alias or function named `streamrip` is shadowing it — aliases and +> functions are checked before `PATH`, venv or no venv. Run +> `command -v streamrip` (or, in fish, `type streamrip`) to see what it +> actually resolves to. `command streamrip` bypasses the alias/function and +> runs the real one from the active venv. > [!IMPORTANT] > `pip install streamrip` (PyPI), the AUR package and `brew install streamrip` @@ -163,19 +164,19 @@ If a saved login stops working, streamrip offers to log in again. Download an album from Qobuz ```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 ``` Download multiple albums from Qobuz ```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 +streamrip 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 ``` Download the album and convert it to `mp3` ```bash -rip --codec mp3 url https://open.qobuz.com/album/0060253780968 +streamrip --codec mp3 url https://open.qobuz.com/album/0060253780968 ``` To set the maximum quality, use the `--quality` option to `0, 1, 2, 3, 4`: @@ -189,7 +190,7 @@ To set the maximum quality, use the `--quality` option to `0, 1, 2, 3, 4`: | 4 | 24 bit, ≤ 192 kHz | Qobuz | ```bash -rip --quality 3 url https://tidal.com/browse/album/147569387 +streamrip --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. @@ -197,7 +198,7 @@ rip --quality 3 url https://tidal.com/browse/album/147569387 Search for playlists matching `rap` on Tidal ```bash -rip search tidal playlist 'rap' +streamrip search tidal playlist 'rap' ``` ![streamrip interactive search](https://github.com/Stensel8/streamrip/blob/dev/demo/playlist_search.png?raw=true) @@ -205,25 +206,25 @@ rip search tidal playlist 'rap' Search for *Rumours* on Tidal, and download it ```bash -rip search tidal album 'fleetwood mac rumours' +streamrip search tidal album 'fleetwood mac rumours' ``` Download a last.fm playlist using the lastfm command ``` -rip lastfm https://www.last.fm/user/nathan3895/playlists/12126195 +streamrip lastfm https://www.last.fm/user/nathan3895/playlists/12126195 ``` For more customization, see the config file ``` -rip config open +streamrip 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 +If you're confused about anything, see the help pages. The main help pages can be accessed by typing `streamrip` 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 +streamrip url --help ``` ![example_help_page.png](https://github.com/Stensel8/streamrip/blob/dev/demo/example_help_page.png?raw=true) diff --git a/install.ps1 b/install.ps1 index 5271d550..226b488b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -22,17 +22,17 @@ uv pip install --python .venv --upgrade ` "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" Write-Host "" -$version = & .venv\Scripts\rip.exe --version 2>$null +$version = & .venv\Scripts\streamrip.exe --version 2>$null if ($LASTEXITCODE -eq 0) { - Write-Host "Done. $version is installed at .venv\Scripts\rip.exe." + Write-Host "Done. $version is installed at .venv\Scripts\streamrip.exe." } else { - Write-Host "Install finished, but .venv\Scripts\rip.exe --version failed -- something's wrong." + 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 'rip' works, then use it normally:" +Write-Host "Activate the venv so plain 'streamrip' works, then use it normally:" Write-Host " .venv\Scripts\Activate.ps1" Write-Host "" -Write-Host "If 'rip' then runs something else, an alias or function named rip" -Write-Host "is shadowing it (check with 'Get-Command rip -All')." -Write-Host ".venv\Scripts\rip.exe always works regardless of that, activated or not." +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 index 3147d842..a92924de 100755 --- a/install.sh +++ b/install.sh @@ -24,17 +24,18 @@ uv pip install --python .venv --upgrade \ "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" echo -if .venv/bin/rip --version >/dev/null 2>&1; then - echo "Done. $(.venv/bin/rip --version) is installed at .venv/bin/rip." +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/rip --version failed -- something's wrong." + echo "Install finished, but .venv/bin/streamrip --version failed -- something's wrong." exit 1 fi echo -echo "Activate the venv so plain 'rip' works, then use it normally:" +echo "Activate the venv so plain 'streamrip' works, then use it normally:" echo " source .venv/bin/activate # bash/zsh" echo " source .venv/bin/activate.fish # fish" echo -echo "If 'rip' then runs something else, a shell alias or function named rip" -echo "is shadowing it (check with 'command -v rip', or 'type rip' in fish)." -echo ".venv/bin/rip always works regardless of that, activated or not." +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/pyproject.toml b/pyproject.toml index 7338b25b..1e323efc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ qobuz-login = ["playwright>=1.49"] ffmpeg = ["imageio-ffmpeg>=0.5"] [project.scripts] -rip = "streamrip.rip:rip" +streamrip = "streamrip.rip:rip" [project.urls] Homepage = "https://github.com/Stensel8/streamrip" diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 984a19a0..e01b3574 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -288,7 +288,7 @@ 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; `rip --quality 3/4` used to IndexError. + # 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} diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 86b87541..734e001c 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -433,7 +433,7 @@ async def get_user_playlists(self, limit: int = 500) -> list[dict]: async def get_downloadable(self, item: str, quality: int) -> Downloadable: assert self.secret is not None and self.logged_in # Qobuz has no quality 0 (128 kbps); clamp instead of asserting so - # `rip --quality 0` still works for mixed-source downloads. + # `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) if status != 200: diff --git a/streamrip/config.py b/streamrip/config.py index 6f7e4473..933d22e4 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -48,7 +48,7 @@ class TidalConfig: 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` + # in again using `streamrip config --tidal` token_expiry: str # 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 FLAC, 3: best available (24-bit # FLAC where the client id is allowed to stream it) @@ -82,7 +82,7 @@ class DeezerConfig: # 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 + # This warns you when the paid deezer account is not logged in and streamrip falls # back to deezloader, which is unreliable deezloader_warnings: bool diff --git a/streamrip/config.toml b/streamrip/config.toml index dcf38b94..87389cea 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -66,7 +66,7 @@ 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` +# in again using `streamrip config --tidal` token_expiry = "" # The client id the tokens above were issued to token_client_id = "" @@ -86,7 +86,7 @@ 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 +# This warns you when the paid deezer account is not logged in and streamrip falls # back to deezloader, which is unreliable deezloader_warnings = true @@ -112,7 +112,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 = "" diff --git a/streamrip/media/track.py b/streamrip/media/track.py index bf5c41df..27417e19 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -173,7 +173,7 @@ async def resolve(self) -> Track | None: source = self.client.source # Every failure below has to be recorded, not just logged. An unlogged # failure leaves no trace anywhere: no file, no downloads.db row, and - # nothing in the failed db for `rip repair` to retry -- the track just + # nothing in the failed db for `streamrip repair` to retry -- the track just # silently goes missing from the album. try: resp = await self.client.get_metadata(self.id, "track") @@ -241,7 +241,7 @@ async def resolve(self) -> Track | None: return None # As in PendingTrack.resolve: record every failure, so a track that - # dies here is retryable by `rip repair` instead of vanishing. + # dies here is retryable by `streamrip repair` instead of vanishing. try: resp = await self.client.get_metadata(self.id, "track") except NonStreamableError as e: @@ -296,7 +296,7 @@ async def resolve(self) -> Track | None: # Mirror PendingTrack: a track belonging to a multi-disc album lives in # that album's disc subfolder. Without this, downloading one track of a - # multi-disc album (`rip repair`, or any single-track URL) drops it + # multi-disc album (`streamrip repair`, or any single-track URL) drops it # beside the Disc folders rather than into the one it belongs to. # Only meaningful when we're actually building the album's folder -- # otherwise this would create a bare "Disc N" in the download root. diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index 768c09a1..8374d1a9 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -125,7 +125,7 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: # 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 - # `rip repair`), which then loses the "Disc N" subfolder. + # `streamrip repair`), which then loses the "Disc N" subfolder. resp.get("media_count") or max( track.get("media_number", 1) diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 8620c46b..6b666088 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -37,7 +37,7 @@ def coro(f): def wrapper(*args, **kwargs): async def run(): # Ctrl-C used to be ignored until whatever was in flight finished, - # so people force-killed rip -- which skips the cleanup in + # 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() @@ -170,7 +170,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 @@ -259,7 +259,7 @@ async def file(ctx, path): Example usage: - rip file urls.txt + streamrip file urls.txt """ if ctx.obj["config"] is None: return @@ -524,7 +524,7 @@ async def repair(ctx, yes, flat): 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]rip repair[/bold] to try again." + "and are still logged. Run [bold]streamrip repair[/bold] to try again." ) @@ -558,7 +558,7 @@ 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 diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 0c5adb15..780f8839 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -169,7 +169,7 @@ 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: rip is run from + # 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(): @@ -347,6 +347,6 @@ async def __aexit__(self, *_): # 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/utils/ssl_utils.py b/streamrip/utils/ssl_utils.py index d897dfc9..9f05ef36 100644 --- a/streamrip/utils/ssl_utils.py +++ b/streamrip/utils/ssl_utils.py @@ -64,7 +64,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/test_config.toml b/tests/test_config.toml index c00a9977..fa04ec6f 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -56,7 +56,7 @@ 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` +# in again using `streamrip config --tidal` token_expiry = "tokenexpiry" token_client_id = "" @@ -74,7 +74,7 @@ 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 +# This warns you when the paid deezer account is not logged in and streamrip falls # back to deezloader, which is unreliable deezloader_warnings = true @@ -100,7 +100,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" 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_deezer_client.py b/tests/test_deezer_client.py index 3a9d6868..600f3830 100644 --- a/tests/test_deezer_client.py +++ b/tests/test_deezer_client.py @@ -53,7 +53,7 @@ async def test_clamp_can_be_disabled(): @pytest.mark.asyncio async def test_out_of_range_quality_does_not_crash(): - """`rip --quality 4` used to raise IndexError for Deezer.""" + """`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" diff --git a/tests/test_qobuz_errors.py b/tests/test_qobuz_errors.py index 6e448964..c42f029f 100644 --- a/tests/test_qobuz_errors.py +++ b/tests/test_qobuz_errors.py @@ -1,6 +1,6 @@ """Qobuz failures should explain themselves, and never quote credentials. -`rip search qobuz ...` crashed with a bare `AssertionError: 400`. The 400 was +`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 diff --git a/tests/test_reauth_prompt.py b/tests/test_reauth_prompt.py index 12364a82..90350ac2 100644 --- a/tests/test_reauth_prompt.py +++ b/tests/test_reauth_prompt.py @@ -62,7 +62,7 @@ async def test_propagates_when_user_declines(): @pytest.mark.asyncio async def test_never_prompts_without_a_terminal(): - """rip runs from cron and from scripts, where a hidden y/n hangs forever.""" + """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"): From 4ab1e233042ae4511aed8c9b2dde4cf6e944e439 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:54:53 +0200 Subject: [PATCH 53/62] fix(install): show one activation line, not two; mention deactivate (#6) --- install.ps1 | 3 ++- install.sh | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 226b488b..587dd6ea 100644 --- a/install.ps1 +++ b/install.ps1 @@ -30,8 +30,9 @@ if ($LASTEXITCODE -eq 0) { exit 1 } Write-Host "" -Write-Host "Activate the venv so plain 'streamrip' works, then use it normally:" +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')." diff --git a/install.sh b/install.sh index a92924de..dab5dc78 100755 --- a/install.sh +++ b/install.sh @@ -31,9 +31,20 @@ else exit 1 fi echo -echo "Activate the venv so plain 'streamrip' works, then use it normally:" -echo " source .venv/bin/activate # bash/zsh" -echo " source .venv/bin/activate.fish # fish" +# 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" From 35200d89e08bb4a1892eb25589e7dddbc0132fd1 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:53:44 +0200 Subject: [PATCH 54/62] fix: artist download UX, Tidal rate limits, quality-downgrade warning (#7) * fix(search): don't show a made-up "Unknown Albums" in the preview * fix(ui): announce artist/label downloads before the first progress bar * fix(config): lower the default rate limit to avoid 429s * fix(tidal): warn when Tidal serves a lower quality than requested * docs: drop the extras/alias-shadowing detours, credit rip upstream * feat(qobuz): bundle browser-based login, no manual extra/install step * docs: drop the ffmpeg extra from install.sh/ps1, reframe the intro note * fix: chunk the repeats-filter resolve phase too, cover no-warning path --- README.md | 61 ++++++--------------- install.ps1 | 2 +- install.sh | 2 +- poetry.lock | 15 ++---- pyproject.toml | 6 ++- streamrip/client/tidal.py | 22 ++++++++ streamrip/config.toml | 10 ++-- streamrip/media/artist.py | 33 ++++++++---- streamrip/media/label.py | 15 ++++-- streamrip/metadata/search_results.py | 9 +++- streamrip/rip/prompter.py | 6 +-- streamrip/rip/qobuz_token_capture.py | 50 +++++++++++++++--- tests/test_artist.py | 57 ++++++++++++++++++++ tests/test_qobuz_token_capture.py | 79 ++++++++++++++++++++++++++++ tests/test_search_results.py | 16 ++++++ tests/test_tidal.py | 38 +++++++++++++ 16 files changed, 336 insertions(+), 85 deletions(-) create mode 100644 tests/test_artist.py create mode 100644 tests/test_qobuz_token_capture.py create mode 100644 tests/test_search_results.py diff --git a/README.md b/README.md index 8c901556..cf34e346 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,13 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. > [!NOTE] -> This is a maintained fork of [nathom/streamrip](https://github.com/nathom/streamrip). -> It targets current Python (3.14+) and fixes a large part of the open -> upstream issues and pull requests; see [CHANGELOG.md](CHANGELOG.md) for what -> changed and which upstream issues are addressed. +> This is a fork of [nathom/streamrip](https://github.com/nathom/streamrip) +> that gets it working again: current Python (3.14+), and a large part of the +> open upstream issues and pull requests worked through, so downloads across +> all four sources work again for now; see [CHANGELOG.md](CHANGELOG.md) for +> what changed. Streaming services change their APIs without notice and this +> will need upkeep to keep working — there's no promise it stays maintained +> forever, just that it does right now. ![downloading an album](https://github.com/Stensel8/streamrip/blob/dev/demo/download_album.png?raw=true) @@ -29,8 +32,10 @@ A scriptable stream downloader for Qobuz, Tidal, Deezer and SoundCloud. ## Installation The fastest way to get going: run the install script for your OS. It fetches -Python 3.14 and ffmpeg for you (via [uv](https://docs.astral.sh/uv/)) and -installs streamrip into a `.venv` in the current directory: +Python 3.14 for you (via [uv](https://docs.astral.sh/uv/)) and installs +streamrip into a `.venv` in the current directory. Install +[`ffmpeg`](https://ffmpeg.org/download.html) yourself first if you'll need +conversion or Tidal hi-res downloads: ```bash # Linux/macOS @@ -46,12 +51,11 @@ Then activate that venv and run `streamrip`, same as the manual steps below. Rea on if you'd rather do each step yourself, or already have Python and ffmpeg set up. -First, ensure [Python](https://www.python.org/downloads/) 3.14 or newer and -[pip](https://pip.pypa.io/en/stable/installing/) are installed. You'll also -need `ffmpeg` for conversion and Tidal hi-res downloads: either install it -yourself (e.g. `apt install ffmpeg`, `brew install ffmpeg`, -[ffmpeg.org](https://ffmpeg.org/download.html) for Windows), or skip that and -add the `ffmpeg` extra below to get a working one bundled with streamrip. +First, ensure [Python](https://www.python.org/downloads/) 3.14 or newer, +[pip](https://pip.pypa.io/en/stable/installing/), and +[`ffmpeg`](https://ffmpeg.org/download.html) are installed (e.g. +`apt install ffmpeg`, `brew install ffmpeg`). `ffmpeg` is needed for +conversion and Tidal hi-res downloads. Always install Python packages into a virtual environment, never into your system Python. Create one and activate it: @@ -99,43 +103,11 @@ streamrip 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) in the upstream wiki. -> [!TIP] -> If `streamrip` runs something else entirely (not a streamrip help page), a -> shell alias or function named `streamrip` is shadowing it — aliases and -> functions are checked before `PATH`, venv or no venv. Run -> `command -v streamrip` (or, in fish, `type streamrip`) to see what it -> actually resolves to. `command streamrip` bypasses the alias/function and -> runs the real one from the active venv. - > [!IMPORTANT] > `pip install streamrip` (PyPI), the AUR package and `brew install streamrip` > all install **upstream** streamrip, not this fork. Upstream's release pins > `Pillow<11`, which has no wheels for Python 3.14 and fails to build. -### Optional extras - -Add the extra in brackets to the install command for whichever method you -used above. With the venv method: - -```bash -# Bundle a working ffmpeg, so there's no separate OS-level install -pip install "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" - -# Use certifi's CA bundle instead of the system certificates -pip install "streamrip[ssl] @ git+https://github.com/Stensel8/streamrip.git" - -# Let streamrip capture your Qobuz login token from a real browser -pip install "streamrip[qobuz-login] @ git+https://github.com/Stensel8/streamrip.git" -playwright install chromium -``` - -pipx and uv take the same `package[extra] @ url` spec, for example: - -```bash -pipx install "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" -uv tool install "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" -``` - ### Logging in - **Qobuz** moved its web login behind a captcha, so email/password login no @@ -270,6 +242,7 @@ Thanks to Vitiko98, Sorrow446, and DashLt for their contributions to this projec `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) diff --git a/install.ps1 b/install.ps1 index 587dd6ea..9550a3ae 100644 --- a/install.ps1 +++ b/install.ps1 @@ -19,7 +19,7 @@ if (-not (Test-Path .venv)) { Write-Host "Installing streamrip into .venv..." uv pip install --python .venv --upgrade ` - "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" + "streamrip @ git+https://github.com/Stensel8/streamrip.git" Write-Host "" $version = & .venv\Scripts\streamrip.exe --version 2>$null diff --git a/install.sh b/install.sh index dab5dc78..df0422c4 100755 --- a/install.sh +++ b/install.sh @@ -21,7 +21,7 @@ fi echo "Installing streamrip into .venv..." uv pip install --python .venv --upgrade \ - "streamrip[ffmpeg] @ git+https://github.com/Stensel8/streamrip.git" + "streamrip @ git+https://github.com/Stensel8/streamrip.git" echo if .venv/bin/streamrip --version >/dev/null 2>&1; then diff --git a/poetry.lock b/poetry.lock index b54add48..a8b6796c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -600,10 +600,9 @@ files = [ name = "greenlet" version = "3.5.6" description = "Lightweight in-process concurrent programming" -optional = true +optional = false python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"qobuz-login\"" files = [ {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"}, @@ -1133,10 +1132,9 @@ xmp = ["defusedxml"] name = "playwright" version = "1.63.0" description = "A high-level API to automate web browsers" -optional = true +optional = false python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"qobuz-login\"" files = [ {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"}, @@ -1371,10 +1369,9 @@ files = [ 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 = true +optional = false python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"qobuz-login\"" files = [ {file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"}, {file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"}, @@ -1558,10 +1555,9 @@ files = [ name = "typing-extensions" version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" -optional = true +optional = false python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"qobuz-login\"" files = [ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, @@ -1769,10 +1765,9 @@ propcache = ">=0.2.1" [extras] ffmpeg = ["imageio-ffmpeg"] -qobuz-login = ["playwright"] ssl = ["certifi"] [metadata] lock-version = "2.1" python-versions = ">=3.14,<4.0" -content-hash = "b095cb652034ebc3bbad736d8fc55b994b5e63b3182c845af4e730f2dfd4140a" +content-hash = "9e5e1fd4fbf1db463ae9e99e5216452be540ab4f5e92cc5d2130694643683a2d" diff --git a/pyproject.toml b/pyproject.toml index 1e323efc..e78e777e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,13 +31,15 @@ dependencies = [ "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"] -# Capture the Qobuz user_auth_token from a real browser login. -qobuz-login = ["playwright>=1.49"] # A prebuilt ffmpeg, for when there's no system ffmpeg (or none on PATH). ffmpeg = ["imageio-ffmpeg>=0.5"] diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 48d9be23..1abd09f7 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -57,6 +57,19 @@ def _b64(s: str) -> str: 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, +} + class TidalClient(Client): """TidalClient.""" @@ -233,6 +246,15 @@ async def get_downloadable(self, track_id: str, quality: int): resp.get("userMessage") or f"No stream available for track {track_id}" ) + actual_quality = resp.get("audioQuality") + actual_tier = _AUDIO_QUALITY_TIER.get(actual_quality) + if actual_tier is not None and actual_tier < quality: + 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)." + ) + # 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. diff --git a/streamrip/config.toml b/streamrip/config.toml index 87389cea..ed9fc8b6 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -15,8 +15,11 @@ concurrency = true # Set to -1 for no limit 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 = 40 # Verify SSL certificates for API connections # Set to false if you encounter SSL certificate verification errors (not recommended) verify_ssl = true @@ -33,7 +36,8 @@ download_booklets = true # 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. -# `pip install 'streamrip[qobuz-login]'` lets streamrip capture them for you. +# 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 = "" diff --git a/streamrip/media/artist.py b/streamrip/media/artist.py index 854f6ea0..c5556f55 100644 --- a/streamrip/media/artist.py +++ b/streamrip/media/artist.py @@ -14,10 +14,11 @@ 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 +# Resolve only N albums at a time to avoid initial latency of resolving ALL +# albums and tracks before any downloads, and to avoid bursting past the +# streaming service's rate limit: resolving + starting downloads for many +# albums at once fires many API calls almost simultaneously. +RESOLVE_CHUNK_SIZE = 4 @dataclass(slots=True) @@ -33,6 +34,14 @@ async def preprocess(self): pass async def download(self): + # 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.qobuz_filters if filter_conf.repeats: console.log( @@ -49,12 +58,18 @@ async def _resolve_then_download(self, filters: QobuzDiscographyFilterConfig): """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. + of all albums to remove repeated items. Resolving still needs every + album's title before filtering can happen, but that doesn't mean + firing every resolve request at once -- chunk it the same as the + download phase below, so this path doesn't burst past the streaming + service's rate limit either. """ - resolved_or_none: list[Album | None] = await asyncio.gather( - *[album.resolve() for album in self.albums] - ) - resolved = [a for a in resolved_or_none if a is not None] + resolved: list[Album] = [] + for chunk in self.batch(self.albums, RESOLVE_CHUNK_SIZE): + resolved_or_none: list[Album | None] = await asyncio.gather( + *[album.resolve() for album in chunk] + ) + resolved.extend(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: diff --git a/streamrip/media/label.py b/streamrip/media/label.py index 5aa0e807..59b80d1d 100644 --- a/streamrip/media/label.py +++ b/streamrip/media/label.py @@ -6,6 +6,7 @@ from ..client import Client from ..config import Config +from ..console import console from ..db import Database from ..metadata import LabelMetadata from .album import PendingAlbum @@ -27,10 +28,16 @@ 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 + # 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..." + ) + # Resolve only a few albums at a time to avoid the initial latency + # of resolving ALL albums and tracks before any downloads start. + album_resolve_chunk_size = 4 async def _resolve_download(item: PendingAlbum): album = await item.resolve() diff --git a/streamrip/metadata/search_results.py b/streamrip/metadata/search_results.py index f618f191..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) diff --git a/streamrip/rip/prompter.py b/streamrip/rip/prompter.py index a511b8a1..4cba4dce 100644 --- a/streamrip/rip/prompter.py +++ b/streamrip/rip/prompter.py @@ -101,10 +101,10 @@ async def _prompt_creds_and_set_session_config(self): 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 installed. + """Grab the token from a real browser login, if Playwright is usable. - Install with ``pip install 'streamrip[qobuz-login]'`` and run - ``playwright install chromium`` once. + The Chromium build it needs downloads itself automatically the + first time this actually runs. """ if not playwright_available(): return False diff --git a/streamrip/rip/qobuz_token_capture.py b/streamrip/rip/qobuz_token_capture.py index 91c5977d..0034dc67 100644 --- a/streamrip/rip/qobuz_token_capture.py +++ b/streamrip/rip/qobuz_token_capture.py @@ -3,16 +3,21 @@ 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 is an optional dependency: ``pip install -'streamrip[qobuz-login]'`` followed by ``playwright install chromium``. +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" @@ -28,6 +33,34 @@ def playwright_available() -> bool: 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. @@ -38,9 +71,8 @@ async def _capture_qobuz_auth_token_async(timeout_s: int = 300) -> tuple[str, st from playwright.async_api import async_playwright except Exception as exc: # pragma: no cover - import path only raise QobuzTokenCaptureError( - "Automatic browser capture requires Playwright. Install it with " - "`pip install 'streamrip[qobuz-login]'` and run " - "`playwright install chromium`, or enter the token manually." + "Automatic browser capture requires Playwright, which failed to " + "import. Reinstall streamrip, or enter the token manually." ) from exc result: dict[str, str] = {} @@ -73,7 +105,13 @@ async def handle_response(response): try: async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=False) + 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) diff --git a/tests/test_artist.py b/tests/test_artist.py new file mode 100644 index 00000000..7138e53d --- /dev/null +++ b/tests/test_artist.py @@ -0,0 +1,57 @@ +import asyncio + +import pytest + +from streamrip.config import QobuzDiscographyFilterConfig +from streamrip.media.artist import RESOLVE_CHUNK_SIZE, Artist + + +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 = QobuzDiscographyFilterConfig( + extras=False, + repeats=False, + non_albums=False, + features=False, + non_studio_albums=False, + non_remaster=False, +) + + +@pytest.mark.asyncio +async def test_resolve_then_download_chunks_the_resolve_phase(): + """Used when qobuz_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 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_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_tidal.py b/tests/test_tidal.py index dbe0b652..c67b1709 100644 --- a/tests/test_tidal.py +++ b/tests/test_tidal.py @@ -108,6 +108,44 @@ async def test_missing_manifest_is_non_streamable(): await c.get_downloadable("1", 2) +@pytest.mark.asyncio +async def test_warns_when_served_quality_is_lower_than_requested(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(), + } + ) + await c.get_downloadable("1", 3) # requested HI_RES, only LOSSLESS available + assert "requested HI_RES but Tidal only has LOSSLESS" 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) + + @pytest.mark.asyncio async def test_single_search_hit_is_returned(): c = _client() From 185f8b76110ff092a3865890bb759739179270b5 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:20:00 +0200 Subject: [PATCH 55/62] fix(tidal): default quality to LOSSLESS, not best-available (#8) * fix(tidal): default quality to LOSSLESS, not best-available --- streamrip/config.toml | 8 ++++++-- tests/test_tidal.py | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/streamrip/config.toml b/streamrip/config.toml index ed9fc8b6..7921e4fc 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -50,8 +50,12 @@ secrets = [] [tidal] # 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) -quality = 3 +# (24-bit FLAC where the client below is allowed to stream it). +# Default is 2, not 3: most tracks don't have a hi-res master, so requesting +# 3 for everything mostly just downloads the same 16/44.1 FLAC anyway, with +# a "no lossless master" warning logged per track that doesn't have one. +# Set to 3 if you want hi-res where it exists. +quality = 2 # This will download videos included in Video Albums. download_videos = true # Tidal decides per OAuth client which formats it will stream. The default diff --git a/tests/test_tidal.py b/tests/test_tidal.py index c67b1709..39a59c26 100644 --- a/tests/test_tidal.py +++ b/tests/test_tidal.py @@ -41,6 +41,14 @@ def test_default_client_is_the_lossless_one(): assert _client().client_id == DEFAULT_CLIENT_ID +def test_default_quality_is_lossless_not_hires(): + # Regression guard: most catalogs mostly don't have a hi-res master, so + # defaulting to 3 (HI_RES) mainly just produces a warning-per-track for + # no benefit -- see the commit that changed this. A future template + # edit reverting to 3 should fail this test, not surface as log noise. + assert Config.defaults().session.tidal.quality == 2 + + def test_hires_client_can_be_selected(): cfg = Config.defaults() cfg.session.tidal.hires_client = True From afaeaeb8566d0bbe851bcf83b5aa8e40276110a4 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:28:01 +0200 Subject: [PATCH 56/62] feat: improve artist sorting + add option to filter explicit tracks (#9) * fix(tagging): write multiple credited artists as real multi-value tags * feat: prefer FLAC over lossy duplicates; add prefer_explicit option --- CHANGELOG.md | 15 ++++++ streamrip/config.py | 6 +++ streamrip/config.toml | 3 ++ streamrip/media/album.py | 30 ++++++++++- streamrip/media/media.py | 22 +++++++++ streamrip/media/playlist.py | 36 +++++++++++++- streamrip/media/track.py | 53 ++++++++++++++++++++ streamrip/metadata/tagger.py | 16 ++++++ streamrip/metadata/track.py | 27 ++++++---- tests/test_config.py | 15 ++++++ tests/test_config.toml | 3 ++ tests/test_media.py | 61 +++++++++++++++++++++++ tests/test_tagger.py | 13 +++++ tests/test_track.py | 96 ++++++++++++++++++++++++++++++++++++ 14 files changed, 385 insertions(+), 11 deletions(-) create mode 100644 tests/test_media.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d61957e..6b6cccae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,21 @@ upstream issues and pull requests. - 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 (FLAC/AIFF) is already on disk, a lossy + (M4A/MP3) copy of it is no longer downloaded; if the lossy copy already + exists and a lossless one lands afterwards, 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. +- 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 (every track has to be resolved before any of + them download, instead of as each one resolves, so it costs an extra API + call per track). Also fixed: Qobuz tracks were never marked explicit + (`parental_warning` was ignored), which this option depends on. ### Development diff --git a/streamrip/config.py b/streamrip/config.py index 933d22e4..f65df7aa 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -174,6 +174,12 @@ class MetadataConfig: # The following metadata tags won't be applied # See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info exclude: list[str] + # When an album or playlist contains both a clean and an explicit copy of + # the same track (same title and artist), skip the clean one and keep + # only the explicit copy. Off by default since it costs an extra API call + # per track (every track has to be resolved before any of them download, + # instead of downloading as each one resolves). + prefer_explicit: bool = False @dataclass(slots=True) diff --git a/streamrip/config.toml b/streamrip/config.toml index 7921e4fc..7af26b64 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -182,6 +182,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 = false # Changes the folder and file names generated by streamrip. [filepaths] diff --git a/streamrip/media/album.py b/streamrip/media/album.py index d4d6cd16..1af6b7a8 100644 --- a/streamrip/media/album.py +++ b/streamrip/media/album.py @@ -12,7 +12,7 @@ from ..metadata import AlbumMetadata from ..metadata.util import get_album_track_ids from .artwork import download_artwork -from .media import Media, Pending +from .media import Media, Pending, filter_prefer_explicit from .track import PendingTrack logger = logging.getLogger("streamrip") @@ -31,6 +31,10 @@ async def preprocess(self): progress.add_title(self.meta.album) async def download(self): + if self.config.session.metadata.prefer_explicit: + await self._resolve_then_download() + return + async def _resolve_and_download(pending: Pending): try: track = await pending.resolve() @@ -52,6 +56,30 @@ async def _resolve_and_download(pending: Pending): if isinstance(result, Exception): logger.error(f"Album track processing error: {result}") + async def _resolve_then_download(self): + """Resolve every track before downloading any of them, so a clean + copy can be dropped in favor of an explicit one once both are known + (see [metadata] prefer_explicit). Costs one extra API call per track + compared to the default resolve-and-download-immediately path. + """ + resolved = await asyncio.gather(*[p.resolve() for p in self.tracks]) + tracks = filter_prefer_explicit([t for t in resolved if t is not None]) + + async def _download(track): + try: + await track.rip() + except TrackDownloadFailedError: + pass + except Exception as e: + logger.error(f"Error downloading track: {type(e).__name__}: {e}") + + results = await asyncio.gather( + *[_download(t) for t in tracks], return_exceptions=True + ) + for result in results: + if isinstance(result, Exception): + logger.error(f"Album track processing error: {result}") + async def postprocess(self): progress.remove_title(self.meta.album) diff --git a/streamrip/media/media.py b/streamrip/media/media.py index 78ad3945..0f6a04d5 100644 --- a/streamrip/media/media.py +++ b/streamrip/media/media.py @@ -30,3 +30,25 @@ class Pending(ABC): async def resolve(self) -> Media | None: """Fetch metadata and resolve into a downloadable `Media` object.""" raise NotImplementedError + + +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 diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 122d5b39..dc32f3c8 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -26,7 +26,7 @@ ) from ..utils.ssl_utils import get_aiohttp_connector_kwargs from .artwork import download_artwork -from .media import Media, Pending +from .media import Media, Pending, filter_prefer_explicit from .track import Track logger = logging.getLogger("streamrip") @@ -118,6 +118,10 @@ async def postprocess(self): progress.remove_title(self.name) async def download(self): + if self.config.session.metadata.prefer_explicit: + await self._resolve_then_download() + return + track_resolve_chunk_size = 20 async def _resolve_download(item: PendingPlaylistTrack): @@ -145,6 +149,36 @@ async def _resolve_download(item: PendingPlaylistTrack): if isinstance(result, Exception): logger.error(f"Batch processing error: {result}") + async def _resolve_then_download(self): + """Resolve every track before downloading any of them, so a clean + copy can be dropped in favor of an explicit one once both are known + (see [metadata] prefer_explicit). Costs one extra API call per track + compared to the default resolve-and-download-immediately path. + """ + track_resolve_chunk_size = 20 + resolved = [] + for chunk in self.batch(self.tracks, track_resolve_chunk_size): + resolved_or_none = await asyncio.gather(*[t.resolve() for t in chunk]) + resolved.extend(t for t in resolved_or_none if t is not None) + + tracks = filter_prefer_explicit(resolved) + + async def _download(track): + try: + await track.rip() + except TrackDownloadFailedError: + pass + except Exception as e: + logger.error(f"Error downloading track: {type(e).__name__}: {e}") + + for chunk in self.batch(tracks, track_resolve_chunk_size): + results = await asyncio.gather( + *[_download(t) for t in chunk], 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) diff --git a/streamrip/media/track.py b/streamrip/media/track.py index 27417e19..fa498ce5 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -21,6 +21,11 @@ # One try plus three retries with exponential backoff. MAX_DOWNLOAD_ATTEMPTS = 4 +# FLAC is always preferred over a lossy container for the same track: if one +# is already on disk, there's no reason to also keep (or fetch) the other. +LOSSLESS_EXTENSIONS = {"flac", "aiff", "aif"} +LOSSY_EXTENSIONS = {"m4a", "mp3"} + @dataclass(slots=True) class Track(Media): @@ -34,14 +39,30 @@ class Track(Media): # 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._lossless_duplicate_exists(): + 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) async def download(self): + if self._skip_lossy_duplicate: + return async with global_download_semaphore(self.config.session.downloads): for attempt in range(1, MAX_DOWNLOAD_ATTEMPTS + 1): # Retries continue the partial file instead of starting over, @@ -91,6 +112,10 @@ async def download(self): ) 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) @@ -107,6 +132,12 @@ async def postprocess(self): f"Could not convert '{self.meta.title}', keeping the " f"original file: {type(e).__name__}: {e}" ) + else: + # Conversion picks its own target codec for every track, so a + # mismatched sibling from a previous run doesn't mean anything + # there; only clean up when this file's own format is the one on + # disk. + self._remove_lossy_duplicates() self.db.set_downloaded(self.meta.info.id) @@ -146,11 +177,33 @@ 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, 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 _lossless_duplicate_exists(self) -> bool: + if self.downloadable.extension.lower() not in LOSSY_EXTENSIONS: + return False + return any( + os.path.isfile(self._sibling_path(ext)) for ext in LOSSLESS_EXTENSIONS + ) + + def _remove_lossy_duplicates(self): + final_ext = self.download_path.rsplit(".", 1)[-1].lower() + if final_ext not in LOSSLESS_EXTENSIONS: + return + for ext in LOSSY_EXTENSIONS: + sibling = self._sibling_path(ext) + if os.path.isfile(sibling): + logger.info(f"Removing lower-quality duplicate: {sibling}") + os.remove(sibling) + @dataclass(slots=True) class PendingTrack(Pending): diff --git a/streamrip/metadata/tagger.py b/streamrip/metadata/tagger.py index 38dd8084..eb6354de 100644 --- a/streamrip/metadata/tagger.py +++ b/streamrip/metadata/tagger.py @@ -146,6 +146,13 @@ def get_tag_pairs(self, meta, exclude=()) -> list[tuple]: def _tag_flac(self, meta: TrackMetadata) -> list[tuple]: out = [] for k, v in FLAC_KEY.items(): + if k == "artist" and meta.artists and len(meta.artists) > 1: + # Multiple separate ARTIST fields (Vorbis comments support + # repeated keys) instead of one "A, B" string, so players + # don't have to guess where to split it. + out.append((v, meta.artists)) + continue + tag = self._attr_from_meta(meta, k) if tag: if k in { @@ -166,6 +173,12 @@ def _tag_mp3(self, meta: TrackMetadata): text = f"{meta.tracknumber}/{meta.album.tracktotal}" elif k == "discnumber": text = f"{meta.discnumber}/{meta.album.disctotal}" + elif k == "artist" and meta.artists and len(meta.artists) > 1: + # A real multi-value TPE1; save_audio()'s update_to_v23() + + # v2_version=3 joins these with "/" (the separator players + # actually recognize), instead of us baking in ", " which + # is indistinguishable from a comma inside one artist's name. + text = meta.artists else: text = self._attr_from_meta(meta, k) @@ -185,6 +198,9 @@ def _tag_mp4(self, meta: TrackMetadata): # we have to pass in the actual bytes to mutagen # See mutagen.MP4Tags.__render_freeform text = meta.isrc.encode("utf-8") + elif k == "artist" and meta.artists and len(meta.artists) > 1: + # Multiple values under \xa9ART instead of one joined string. + text = meta.artists else: text = self._attr_from_meta(meta, k) diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index 21fb1686..71bc5b02 100644 --- a/streamrip/metadata/track.py +++ b/streamrip/metadata/track.py @@ -33,6 +33,12 @@ 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: @@ -64,8 +70,7 @@ def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None: 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 + explicit = typed(resp.get("parental_warning", False), bool) info = TrackInfo( id=track_id, @@ -96,9 +101,10 @@ def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None: work = None title = typed(resp["title"], str) contributors = resp.get("contributors", []) - artist = ", ".join( - c["name"] for c in contributors if c["type"] == "artist" - ) or typed(resp["artist"]["name"], str) + artist_names = [c["name"] for c in contributors if c["type"] == "artist"] or [ + typed(resp["artist"]["name"], str) + ] + artist = ", ".join(artist_names) tracknumber = typed(resp["track_position"], int) discnumber = typed(resp["disk_number"], int) composer = None @@ -121,6 +127,7 @@ def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None: composer=composer, isrc=isrc, lyrics=lyrics, + artists=artist_names, ) @classmethod @@ -170,11 +177,12 @@ def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata: tracknumber = typed(track.get("trackNumber", 1), int) discnumber = typed(track.get("volumeNumber", 1), int) - artists = track.get("artists") or [] - if len(artists) > 0: - artist = ", ".join(a["name"] for a in artists) + track_artists = track.get("artists") or [] + if len(track_artists) > 0: + artist_names = [a["name"] for a in track_artists] else: - artist = track["artist"]["name"] + artist_names = [track["artist"]["name"]] + artist = ", ".join(artist_names) lyrics = track.get("lyrics", "") @@ -207,6 +215,7 @@ def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata: composer=None, isrc=isrc, lyrics=lyrics, + artists=artist_names, ) @classmethod diff --git a/tests/test_config.py b/tests/test_config.py index f0cee2b1..dc1a4641 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -290,6 +290,21 @@ def test_config_dont_update_without_set_modified(): assert conf2.session.downloads.folder == "test_folder" +def test_prefer_explicit_defaults_false(): + assert Config.defaults().session.metadata.prefer_explicit is False + + +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 = false\n", "\n") + assert "prefer_explicit" not in toml_str + data = ConfigData.from_toml(toml_str) + assert data.metadata.prefer_explicit is False + + # Other tests for the Config class can be added as needed if __name__ == "__main__": diff --git a/tests/test_config.toml b/tests/test_config.toml index fa04ec6f..a88f5bed 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -161,6 +161,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 = false # Changes the folder and file names generated by streamrip. [filepaths] diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100644 index 00000000..9546b95e --- /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", quality=2, 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_tagger.py b/tests/test_tagger.py index 1bea4810..afdde2d5 100644 --- a/tests/test_tagger.py +++ b/tests/test_tagger.py @@ -109,3 +109,16 @@ def test_tag_flac_cover(sample_metadata): 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) diff --git a/tests/test_track.py b/tests/test_track.py index 26b168fa..5653c7eb 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -7,7 +7,15 @@ import streamrip.db as db from streamrip.client.downloadable import Downloadable from streamrip.client.qobuz import QobuzClient +from streamrip.config import Config from streamrip.media.track import PendingSingle, Track +from streamrip.metadata import ( + AlbumInfo, + AlbumMetadata, + Covers, + TrackInfo, + TrackMetadata, +) @pytest.mark.skipif( @@ -30,3 +38,91 @@ def test_pending_resolve(qobuz_client: QobuzClient): assert isinstance(t.downloadable, Downloadable) assert t.cover_path is not None shutil.rmtree(dir) + + +class FakeDownloadable(Downloadable): + """A Downloadable that just writes some bytes, for exercising Track's + file-placement logic without hitting the network. + """ + + def __init__(self, extension: str): + self.extension = extension + self.source = "test" + + async def _download(self, path, callback): + data = b"fake audio bytes" + with open(path, "wb") as f: + f.write(data) + callback(len(data)) + + async def size(self): + return 17 + + +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", quality=2), + 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): + (tmp_path / "Song.flac").write_bytes(b"pretend flac bytes") + + t = _make_track(str(tmp_path), "m4a") + arun(t.preprocess()) + assert t._skip_lossy_duplicate is True + + arun(t.download()) + assert not (tmp_path / "Song.m4a").exists() + + +def test_lossy_download_proceeds_without_a_lossless_copy(tmp_path): + t = _make_track(str(tmp_path), "m4a") + arun(t.preprocess()) + assert t._skip_lossy_duplicate is False + + arun(t.download()) + assert (tmp_path / "Song.m4a").exists() + + +def test_stale_lossy_sibling_removed_once_lossless_copy_lands(tmp_path): + (tmp_path / "Song.m4a").write_bytes(b"stale aac copy") + + t = _make_track(str(tmp_path), "flac") + arun(t.preprocess()) + assert t._skip_lossy_duplicate is False + + arun(t.download()) + assert (tmp_path / "Song.flac").exists() + assert (tmp_path / "Song.m4a").exists() # not cleaned up until postprocess + + t._remove_lossy_duplicates() + assert not (tmp_path / "Song.m4a").exists() From 46ebe7d3af56a51e7a9f216887656b6f41c3dfcb Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:46:36 +0200 Subject: [PATCH 57/62] * feat: add `streamrip database clear` * feat: add `streamrip database clear` --- CHANGELOG.md | 19 ++++ streamrip/client/client.py | 4 + streamrip/client/tidal.py | 92 +++++++++++++++--- streamrip/config.py | 4 +- streamrip/config.toml | 8 +- streamrip/db.py | 7 ++ streamrip/media/album.py | 36 +++++++- streamrip/media/artist.py | 41 +++++---- streamrip/media/media.py | 12 +++ streamrip/media/playlist.py | 6 +- streamrip/rip/cli.py | 55 +++++++++++ tests/test_album.py | 126 +++++++++++++++++++++++++ tests/test_artist.py | 66 +++++++++++++ tests/test_cli.py | 80 ++++++++++++++++ tests/test_db.py | 21 +++++ tests/test_error_handling.py | 8 +- tests/test_tidal.py | 28 +++++- tests/test_tidal_requests.py | 174 +++++++++++++++++++++++++++++++++++ 18 files changed, 740 insertions(+), 47 deletions(-) create mode 100644 tests/test_album.py create mode 100644 tests/test_db.py create mode 100644 tests/test_tidal_requests.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6cccae..7f505e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,13 @@ upstream issues and pull requests. (`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`. ### Deezer @@ -123,6 +130,18 @@ upstream issues and pull requests. them download, instead of as each one resolves, so it costs an extra API call per track). 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. ### Development diff --git a/streamrip/client/client.py b/streamrip/client/client.py index deb82f1b..cf72a57e 100644 --- a/streamrip/client/client.py +++ b/streamrip/client/client.py @@ -16,6 +16,9 @@ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0" ) +# 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 @@ -67,5 +70,6 @@ async def get_session( return aiohttp.ClientSession( headers={"User-Agent": DEFAULT_USER_AGENT} | headers, connector=connector, + timeout=REQUEST_TIMEOUT, trust_env=True, ) diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 1abd09f7..01524425 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -2,6 +2,7 @@ import base64 import json import logging +import random import re import time from json import JSONDecodeError @@ -69,6 +70,21 @@ def _b64(s: str) -> str: "HI_RES": 3, "HI_RES_LOSSLESS": 3, } +LOSSLESS_TIER = _AUDIO_QUALITY_TIER["LOSSLESS"] + +# 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) class TidalClient(Client): @@ -76,6 +92,8 @@ 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): self.logged_in = False @@ -248,12 +266,15 @@ async def get_downloadable(self, track_id: str, quality: int): actual_quality = resp.get("audioQuality") actual_tier = _AUDIO_QUALITY_TIER.get(actual_quality) - if actual_tier is not None and actual_tier < 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 @@ -507,6 +528,9 @@ async def _api_post(self, url, data, auth: bool = False) -> dict: 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: @@ -518,13 +542,59 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: params["countryCode"] = self.config.country_code 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: - # 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}") - resp.raise_for_status() - return await resp.json() + attempt = 0 + while True: + attempt += 1 + if (pause := self._retry_at - time.monotonic()) > 0: + await asyncio.sleep(pause) + + 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): + now = time.monotonic() + if self._retry_at <= now: + logger.warning( + f"Tidal is rate limiting us (HTTP 429); pausing for {seconds:.0f}s" + ) + self._retry_at = max(self._retry_at, now + seconds) diff --git a/streamrip/config.py b/streamrip/config.py index f65df7aa..e40c54f2 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -57,8 +57,8 @@ class TidalConfig: download_videos: bool # Use the OAuth client that is served 24-bit hi-res FLAC. It gets AAC for # ordinary lossless releases, so the default client (FLAC 16/44.1 for - # everything) is usually the better choice. Changing it requires logging in - # again. + # everything) is usually the better choice. Only matters with quality = 3. + # Changing it requires logging in again. hires_client: bool = False # Override the OAuth client id/secret entirely (empty = built-in client). client_id: str = "" diff --git a/streamrip/config.toml b/streamrip/config.toml index 7af26b64..1121934a 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -52,16 +52,16 @@ secrets = [] # 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 2, not 3: most tracks don't have a hi-res master, so requesting -# 3 for everything mostly just downloads the same 16/44.1 FLAC anyway, with -# a "no lossless master" warning logged per track that doesn't have one. -# Set to 3 if you want hi-res where it exists. +# 3 for everything mostly just downloads the same 16/44.1 FLAC anyway. +# Set to 3 (with hires_client = true below) if you want hi-res where it exists. quality = 2 # This will download videos included in Video Albums. download_videos = true # Tidal decides per OAuth client which formats it will stream. The default # client gets FLAC 16/44.1 for every lossless release but never hi-res. # Set this to true to use the client that gets 24-bit FLAC for hi-res releases -# (but AAC 320 for ordinary lossless ones). Changing it requires a new login. +# (but AAC 320 for ordinary lossless ones). It only matters together with +# quality = 3: at 2, hi-res is never requested. Changing it requires a new login. hires_client = false # Override the OAuth client id and secret (leave empty for the built-in one) client_id = "" diff --git a/streamrip/db.py b/streamrip/db.py index 12fda3a9..2abcfba6 100644 --- a/streamrip/db.py +++ b/streamrip/db.py @@ -145,6 +145,13 @@ 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: diff --git a/streamrip/media/album.py b/streamrip/media/album.py index 1af6b7a8..20db72fd 100644 --- a/streamrip/media/album.py +++ b/streamrip/media/album.py @@ -12,11 +12,14 @@ from ..metadata import AlbumMetadata from ..metadata.util import get_album_track_ids from .artwork import download_artwork -from .media import Media, Pending, filter_prefer_explicit +from .media import Media, Pending, filter_prefer_explicit, resolve_or_none from .track import PendingTrack logger = logging.getLogger("streamrip") +# Tracks of an album resolved at once; more only delays the first download. +RESOLVE_CONCURRENCY = 4 + @dataclass(slots=True) class Album(Media): @@ -35,9 +38,12 @@ async def download(self): await self._resolve_then_download() return + resolve_slots = asyncio.Semaphore(RESOLVE_CONCURRENCY) + async def _resolve_and_download(pending: Pending): try: - track = await pending.resolve() + async with resolve_slots: + track = await pending.resolve() if track is None: return await track.rip() @@ -62,7 +68,13 @@ async def _resolve_then_download(self): (see [metadata] prefer_explicit). Costs one extra API call per track compared to the default resolve-and-download-immediately path. """ - resolved = await asyncio.gather(*[p.resolve() for p in self.tracks]) + resolve_slots = asyncio.Semaphore(RESOLVE_CONCURRENCY) + + async def _resolve(pending: Pending): + async with resolve_slots: + return await resolve_or_none(pending) + + resolved = await asyncio.gather(*[_resolve(p) for p in self.tracks]) tracks = filter_prefer_explicit([t for t in resolved if t is not None]) async def _download(track): @@ -113,8 +125,22 @@ async def resolve(self) -> Album | None: return None tracklist = get_album_track_ids(self.client.source, resp) + # 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 = self.config.session.downloads.folder album_folder = self._album_folder(folder, meta) + if tracklist and not todo: + return Album(meta, [], self.config, album_folder, self.db) os.makedirs(album_folder, exist_ok=True) embed_cover, _ = await download_artwork( self.client.session, @@ -125,7 +151,7 @@ async def resolve(self) -> Album | None: ) pending_tracks = [ PendingTrack( - id, + track_id, album=meta, client=self.client, config=self.config, @@ -133,7 +159,7 @@ async def resolve(self) -> Album | None: 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) diff --git a/streamrip/media/artist.py b/streamrip/media/artist.py index c5556f55..91a4df7a 100644 --- a/streamrip/media/artist.py +++ b/streamrip/media/artist.py @@ -76,25 +76,30 @@ async def _resolve_then_download(self, filters: QobuzDiscographyFilterConfig): await asyncio.gather(*batch) async def _download_async(self, filters: QobuzDiscographyFilterConfig): + # Sliding window, not batches: the next album starts as soon as one finishes. + window = asyncio.Semaphore(RESOLVE_CHUNK_SIZE) + 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) + async with window: + try: + 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() + except Exception as e: + logger.error(f"Error downloading album: {type(e).__name__}: {e}") + + await asyncio.gather(*[_rip(album) for album in self.albums]) def _apply_filters( self, albums: list[Album], filt: QobuzDiscographyFilterConfig diff --git a/streamrip/media/media.py b/streamrip/media/media.py index 0f6a04d5..c780a691 100644 --- a/streamrip/media/media.py +++ b/streamrip/media/media.py @@ -1,5 +1,8 @@ +import logging from abc import ABC, abstractmethod +logger = logging.getLogger("streamrip") + class Media(ABC): async def rip(self): @@ -32,6 +35,15 @@ async def resolve(self) -> Media | None: 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: + logger.error(f"Error resolving track: {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. diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index dc32f3c8..5e6e0ccd 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -26,7 +26,7 @@ ) from ..utils.ssl_utils import get_aiohttp_connector_kwargs from .artwork import download_artwork -from .media import Media, Pending, filter_prefer_explicit +from .media import Media, Pending, filter_prefer_explicit, resolve_or_none from .track import Track logger = logging.getLogger("streamrip") @@ -158,7 +158,9 @@ async def _resolve_then_download(self): track_resolve_chunk_size = 20 resolved = [] for chunk in self.batch(self.tracks, track_resolve_chunk_size): - resolved_or_none = await asyncio.gather(*[t.resolve() for t in chunk]) + resolved_or_none = await asyncio.gather( + *[resolve_or_none(t) for t in chunk] + ) resolved.extend(t for t in resolved_or_none if t is not None) tracks = filter_prefer_explicit(resolved) diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 6b666088..809de574 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -399,6 +399,61 @@ def database_browse(ctx, table): ) +@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. diff --git a/tests/test_album.py b/tests/test_album.py new file mode 100644 index 00000000..69bb8b4d --- /dev/null +++ b/tests/test_album.py @@ -0,0 +1,126 @@ +"""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 _album(tracks): + config = MagicMock() + config.session.metadata.prefer_explicit = False + return Album( + meta=MagicMock(), 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 = MagicMock() + meta.album = "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( + PendingAlbum, "_album_folder", lambda self, parent, 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() diff --git a/tests/test_artist.py b/tests/test_artist.py index 7138e53d..7eea9f66 100644 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -42,6 +42,72 @@ async def resolve(self): ) +@pytest.mark.asyncio +async def test_next_album_starts_as_soon_as_a_slot_frees_up(): + """One slow album must not hold back the albums queued behind it.""" + fast = RESOLVE_CHUNK_SIZE * 3 + finished = 0 + release = asyncio.Event() + + class Album: + def __init__(self, slow): + self.slow = slow + + async def rip(self): + nonlocal finished + if self.slow: + # Only finishes once every fast album has, which can't happen + # if the ones behind it wait for it in batches. + await release.wait() + return + finished += 1 + if finished == fast: + release.set() + + class Pending: + def __init__(self, slow): + self.slow = slow + + async def resolve(self): + return Album(self.slow) + + albums = [Pending(True)] + [Pending(False) for _ in range(fast)] + artist = Artist(name="Test Artist", albums=albums, client=None, config=None) + + await asyncio.wait_for(artist._download_async(NO_FILTERS), 5) + + assert finished == fast + + +@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 qobuz_filters.repeats is on: resolving every album's title diff --git a/tests/test_cli.py b/tests/test_cli.py index 0c3e77a2..463b2f3c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,8 @@ +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 @@ -36,3 +39,80 @@ def test_codec_choice_accepts_opus_and_aiff(tmp_path): 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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..99003b1c --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,21 @@ +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") diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 67f6a6e7..452a80e8 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,9 +41,11 @@ 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() diff --git a/tests/test_tidal.py b/tests/test_tidal.py index 39a59c26..1f5f78c4 100644 --- a/tests/test_tidal.py +++ b/tests/test_tidal.py @@ -1,4 +1,5 @@ import base64 +import logging from unittest.mock import AsyncMock, MagicMock import pytest @@ -117,7 +118,26 @@ async def test_missing_manifest_is_non_streamable(): @pytest.mark.asyncio -async def test_warns_when_served_quality_is_lower_than_requested(caplog): +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 = ( @@ -130,8 +150,10 @@ async def test_warns_when_served_quality_is_lower_than_requested(caplog): "manifest": base64.b64encode(manifest.encode()).decode(), } ) - await c.get_downloadable("1", 3) # requested HI_RES, only LOSSLESS available - assert "requested HI_RES but Tidal only has LOSSLESS" in caplog.text + 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 diff --git a/tests/test_tidal_requests.py b/tests/test_tidal_requests.py new file mode 100644 index 00000000..f29233e2 --- /dev/null +++ b/tests/test_tidal_requests.py @@ -0,0 +1,174 @@ +"""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 Client +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 + assert sleeps == [pytest.approx(7, abs=0.5), pytest.approx(7, abs=0.5)] + + +@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 sleeps == [pytest.approx(RATE_LIMIT_PAUSE, abs=0.5)] + + +@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 sleeps == [pytest.approx(MAX_RETRY_DELAY, abs=0.5)] + + +@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 = await Client.get_session() + try: + assert session.timeout.total is None + assert session.timeout.sock_read == 30 + finally: + await session.close() From c90570e72606667a90ab9391e4970551a14e97e6 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:54:50 +0200 Subject: [PATCH 58/62] feat: hi-res Tidal, multi-value artist tags and a leaner codebase (#11) No single Tidal client is served everything any more, so streamrip logs in with two and asks the highest quality first, stepping down when it can't get it. Hi-res (up to 24-bit/192 kHz) works; folder names show the real format. Multiple credited artists are now real multi-value tags. Also: Qobuz booklets download, FLAC is kept over a lossy duplicate, and a large cleanup of the metadata/tagging/track code (see CHANGELOG.md). Tested: 252 tests; two hi-res albums downloaded and checked against Tidal. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- CHANGELOG.md | 111 ++++++- README.md | 291 +++++------------ demo/example_help_page.png | Bin 656489 -> 0 bytes streamrip/client/__init__.py | 3 +- streamrip/client/client.py | 36 +-- streamrip/client/deezer.py | 21 +- streamrip/client/downloadable.py | 128 ++++---- streamrip/client/qobuz.py | 56 +--- streamrip/client/soundcloud.py | 4 +- streamrip/client/tidal.py | 302 ++++++++++++------ streamrip/config.py | 246 ++++----------- streamrip/config.toml | 95 +++--- streamrip/converter.py | 197 ++++-------- streamrip/db.py | 66 +--- streamrip/exceptions.py | 7 - streamrip/media/album.py | 131 +++----- streamrip/media/artist.py | 185 +++++------ streamrip/media/label.py | 30 +- streamrip/media/media.py | 42 ++- streamrip/media/playlist.py | 331 ++++++-------------- streamrip/media/semaphore.py | 23 +- streamrip/media/track.py | 345 ++++++++++----------- streamrip/metadata/album.py | 498 ++++++++---------------------- streamrip/metadata/playlist.py | 42 +-- streamrip/metadata/tagger.py | 296 +++++++----------- streamrip/metadata/track.py | 216 ++++--------- streamrip/metadata/util.py | 16 +- streamrip/progress.py | 1 - streamrip/rip/cli.py | 193 +++++------- streamrip/rip/main.py | 71 ++--- streamrip/rip/parse_url.py | 3 - streamrip/rip/prompter.py | 44 ++- streamrip/utils/ssl_utils.py | 50 +-- tests/silence.m4a | Bin 0 -> 826 bytes tests/silence_alac.m4a | Bin 0 -> 794 bytes tests/test_album.py | 77 ++++- tests/test_artist.py | 69 ++++- tests/test_cli.py | 50 +++ tests/test_config.py | 92 ++++-- tests/test_config.toml | 66 ++-- tests/test_converter.py | 53 ++++ tests/test_db.py | 10 + tests/test_deezer.py | 52 ++-- tests/test_deezer_decrypt.py | 118 +++++++ tests/test_discography_filter.py | 38 +++ tests/test_error_handling.py | 51 +++ tests/test_media.py | 2 +- tests/test_meta.py | 168 +++++++++- tests/test_playlist.py | 131 ++++++++ tests/test_qobuz_download_only.py | 7 +- tests/test_qobuz_errors.py | 2 +- tests/test_soundcloud.py | 85 +++++ tests/test_ssl_verification.py | 373 ++++------------------ tests/test_tagger.py | 101 ++++-- tests/test_tidal.py | 302 +++++++++++++++++- tests/test_tidal_requests.py | 4 +- tests/test_track.py | 161 ++++++++-- 57 files changed, 3066 insertions(+), 3026 deletions(-) delete mode 100644 demo/example_help_page.png create mode 100644 tests/silence.m4a create mode 100644 tests/silence_alac.m4a create mode 100644 tests/test_deezer_decrypt.py create mode 100644 tests/test_playlist.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f505e6e..d080c1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ upstream issues and pull requests. - `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 @@ -25,8 +30,8 @@ upstream issues and pull requests. `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)); optional browser capture with `pip install 'streamrip[qobuz-login]'` - ([PR #955](https://github.com/nathom/streamrip/pull/955)). + [#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)). @@ -39,15 +44,30 @@ upstream issues and pull requests. [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)); optional hi-res client via `hires_client = true`, and hi-res tracks + [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)). Changing - the client asks for a new login once. + ([#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)). @@ -64,6 +84,12 @@ upstream issues and pull requests. - 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 @@ -82,6 +108,11 @@ upstream issues and pull requests. 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 @@ -90,11 +121,15 @@ upstream issues and pull requests. - 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, and `streamrip repair` retries everything in the failed database + 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 @@ -102,12 +137,19 @@ upstream issues and pull requests. - 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 and still records the download +- 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)); MP3s are written as ID3v2.3 as intended. +- `[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)). @@ -119,17 +161,19 @@ upstream issues and pull requests. 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 (FLAC/AIFF) is already on disk, a lossy - (M4A/MP3) copy of it is no longer downloaded; if the lossy copy already - exists and a lossless one lands afterwards, 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. +- 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 (every track has to be resolved before any of - them download, instead of as each one resolves, so it costs an extra API - call per track). Also fixed: Qobuz tracks were never marked explicit - (`parental_warning` was ignored), which this option depends on. + 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 @@ -142,6 +186,39 @@ upstream issues and pull requests. 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 diff --git a/README.md b/README.md index cf34e346..a2cbe147 100644 --- a/README.md +++ b/README.md @@ -4,255 +4,124 @@ [![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. > [!NOTE] -> This is a fork of [nathom/streamrip](https://github.com/nathom/streamrip) -> that gets it working again: current Python (3.14+), and a large part of the -> open upstream issues and pull requests worked through, so downloads across -> all four sources work again for now; see [CHANGELOG.md](CHANGELOG.md) for -> what changed. Streaming services change their APIs without notice and this -> will need upkeep to keep working — there's no promise it stays maintained -> forever, just that it does right now. +> 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. ![downloading an album](https://github.com/Stensel8/streamrip/blob/dev/demo/download_album.png?raw=true) -## Features - -- 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, and `streamrip repair` to retry the ones that failed -- Resumes interrupted downloads and retries with backoff -- Concurrency and rate limiting -- Interactive search for all sources -- Highly customizable through the config file +- 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 -The fastest way to get going: run the install script for your OS. It fetches -Python 3.14 for you (via [uv](https://docs.astral.sh/uv/)) and installs -streamrip into a `.venv` in the current directory. Install -[`ffmpeg`](https://ffmpeg.org/download.html) yourself first if you'll need -conversion or Tidal hi-res downloads: +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 -# Linux/macOS -curl -LsSf https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.sh | bash +curl -LsSf https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.sh | bash # Linux/macOS ``` ```powershell -# Windows (PowerShell) -irm https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.ps1 | iex -``` - -Then activate that venv and run `streamrip`, same as the manual steps below. Read -on if you'd rather do each step yourself, or already have Python and ffmpeg -set up. - -First, ensure [Python](https://www.python.org/downloads/) 3.14 or newer, -[pip](https://pip.pypa.io/en/stable/installing/), and -[`ffmpeg`](https://ffmpeg.org/download.html) are installed (e.g. -`apt install ffmpeg`, `brew install ffmpeg`). `ffmpeg` is needed for -conversion and Tidal hi-res downloads. - -Always install Python packages into a virtual environment, never into your -system Python. Create one and activate it: - -```bash -# bash/zsh -python3 -m venv .venv -source .venv/bin/activate -``` - -```fish -# fish -python3 -m venv .venv -source .venv/bin/activate.fish +irm https://raw.githubusercontent.com/Stensel8/streamrip/dev/install.ps1 | iex # Windows ``` -Then, with the venv active (your prompt shows `(.venv)`), install streamrip -from this repository into it: +With Python 3.14 already there, install it into a venv of your own, or with +[pipx](https://pipx.pypa.io/) or uv: ```bash -pip install --upgrade git+https://github.com/Stensel8/streamrip.git -``` - -`streamrip` is on `PATH` for as long as that venv stays active. Leave it with -`deactivate`; come back to it later from the project directory with the same -`source` command, no need to recreate the venv. - -[pipx](https://pipx.pypa.io/) and [uv](https://docs.astral.sh/uv/) do the same -per-tool venv isolation without the manual `activate` step, if you have either -installed: +python3 -m venv .venv && source .venv/bin/activate # fish: activate.fish +pip install git+https://github.com/Stensel8/streamrip.git -```bash pipx install git+https://github.com/Stensel8/streamrip.git uv tool install git+https://github.com/Stensel8/streamrip.git ``` -To install a specific branch or release, add `@` to the URL, for example -`git+https://github.com/Stensel8/streamrip.git@dev`. - -When you type - -```bash -streamrip -``` - -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) in the upstream wiki. +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`). > [!IMPORTANT] -> `pip install streamrip` (PyPI), the AUR package and `brew install streamrip` -> all install **upstream** streamrip, not this fork. Upstream's release pins -> `Pillow<11`, which has no wheels for Python 3.14 and fails to build. - -### Logging in - -- **Qobuz** moved its web login behind a captcha, so email/password login no - longer works for most accounts. When asked, enter your Qobuz **user id** and - **user_auth_token**: log in at [play.qobuz.com](https://play.qobuz.com/login), - open your browser's DevTools → Network, find the `user/login` request and - copy `user.id` and `user_auth_token` from its response. With the - `qobuz-login` extra installed, streamrip opens a browser and does this for you. - Accounts without a streaming subscription can download albums they bought. -- **Tidal** logs in through your browser (device login). Tidal decides per app - which formats it will stream; by default streamrip uses one that gets FLAC - 16/44.1 for every lossless release. Set `hires_client = true` in the `[tidal]` - section of the config to prefer 24-bit hi-res FLAC instead (ordinary lossless - releases then come as AAC). No developer account or client id is needed. -- **Deezer** needs the `arl` cookie of a logged-in session, see - [Finding your Deezer ARL cookie](https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie). - The download quality is limited to what your subscription allows. -- **SoundCloud** needs no login. - -If a saved login stops working, streamrip offers to log in again. - -## Example Usage - -**For Tidal and Qobuz, you NEED a premium subscription.** - -Download an album from Qobuz - -```bash -streamrip url https://www.qobuz.com/us-en/album/rumours-fleetwood-mac/0603497941032 -``` - -Download multiple albums from Qobuz - -```bash -streamrip 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 -``` - -Download the album and convert it to `mp3` - -```bash -streamrip --codec mp3 url https://open.qobuz.com/album/0060253780968 -``` - -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 (hi-res FLAC), Qobuz, SoundCloud (rarely) | -| 4 | 24 bit, ≤ 192 kHz | Qobuz | +> `pip install streamrip`, the AUR package and `brew install streamrip` install +> **upstream** streamrip, whose last release doesn't install on Python 3.14. + +## Logging in + +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. + +- **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. + +## Usage ```bash +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 -``` - -> 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 -streamrip search tidal playlist 'rap' -``` - -![streamrip interactive search](https://github.com/Stensel8/streamrip/blob/dev/demo/playlist_search.png?raw=true) - -Search for *Rumours* on Tidal, and download it - -```bash -streamrip search tidal album 'fleetwood mac rumours' -``` - -Download a last.fm playlist using the lastfm command - -``` +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 ``` -For more customization, see the config file - -``` -streamrip config open -``` - -If you're confused about anything, see the help pages. The main help pages can be accessed by typing `streamrip` 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 - -``` -streamrip url --help -``` - -![example_help_page.png](https://github.com/Stensel8/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 [upstream wiki](https://github.com/nathom/streamrip/wiki/). - -## Contributions +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): -All contributions are appreciated! You can help out the project by opening an issue -or by submitting code. +| 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 | -### Issues +Every command has a `--help`. -Report problems with this fork in its [issue tracker](https://github.com/Stensel8/streamrip/issues) and -**use the Feature Request or Bug Report templates**, so all the information -needed to debug the issue is there. - -### Code - -- Fork this repository and clone it -- `poetry install --all-extras` sets up a development environment -- `poetry run pytest` and `poetry run ruff check . && poetry run ruff format .` - should pass before you commit -- Open a pull request to the `dev` branch - -Please document any functions or obscure lines of code. Dependencies are kept up -to date by Renovate; Dependabot handles security updates. +![streamrip interactive search](https://github.com/Stensel8/streamrip/blob/dev/demo/playlist_search.png?raw=true) -### The Wiki +## Contributing -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 -streamrip was written by [nathom](https://github.com/nathom); this fork builds -on the work of everyone who sent fixes upstream, whose pull requests are -credited in the commit history and [CHANGELOG.md](CHANGELOG.md). - -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: - -- [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) -- [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 [sponsoring nathom](https://github.com/sponsors/nathom), the original -author of streamrip, if you enjoy it. +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 f92448b897257f242e839cd0f84d100e64702f3f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 656489 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'W)boPaKjX#l-L`?{wI&}~^~{d< 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

2!Fa!rZ{?Bt#jmy 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

i(re`8A9&Hxkad}dpw zUXR+tep|KbTwe~Kd07l&Gd4bh{cbkQwXfS+A}pzLUv%Q}0jgzDADh#IJG0ur?0xJu zMV;?n8)&zADPH4O{>7Bf=wIMz+p+R5`>W?ps%-DwVS8v-f#rfP>gOE?Uxe)1d8fWT zjw&lcZF{;kRxx`H9h@mV9!MRuGwTbS4ClY)JGfr9l9Lmxcbth8C8yjNTJ$-_D%;`J zTa|D3}t;GJKB*93hxtVt3+Ysbt?X-mcDuCsMFCYJQFt6s3hE$mD= zwD0Fdl?U&~#I-4`4_7A$ZSGVs!PK*#D*abKP9EMl_T{yZvi;WFdTa6suiDFSM&&i$ zChNrA``>WDQ5U>jz|KpP55)Pk{@=n5&n@jKOW@=l0uAj1jK$WrHx8wDRCbryM zuPvQU7Y@L8Op<@W;R-4>`t|s(g(>js68d6eY zea-c`$-nh|Ke|A%Q+p=-mfJsQ%@PgPDBsKsnjchPG$S$8Y-ybTPGQ!qs;ULYoypIJ zA+BQ`GQ_QtK-vpKVyq2M_>itbU^92QFUvRtw>tSzY+mLb4RafCsc8%9$X4?S)OdKB z*(1XxEoVul!F|Lwe;>s2l1la1V}a(m1LHG}j*kcm+RqKd9w=H&kk`WUWB) zinfVfMeCBLE!=IrRU;kYt}^$y$Hh;tOb`SKhy=GtOWJr-SR9$92G(X?0ZUL*nqyJ7 zR&|=Y`{1)J1nFU`m^nGRv9|m7F#bu2nc#OR*paJW-KQzvH7%g;jD{Pv;#H%rqMWjH z<6nDnii!k!15116Q=dOQ}IS zgLf{5!F=t|A&k#nyiJ5oU5Qd%LCV*t;VNTvbQ8-%FF$DRt%uQdwK*Sc8OFK2nRd+K zUo$VBi0d^JKy`RmsaD&a`h>WDcT;(@v=X!QvF^LIOVYzj*F-}`f<2s=^!?uTFko8CPco+ZsASABfqI zUfVIX>j!tf6LqGA0&$mTP7UzVa;OnkbDp3wI@E1zpn;4|H!Z#G0W+s`KH~=*Os($* zHN=z?+)IO%ud&(4FL$cNJH>>s>#V|zd};|XJwbJ2LvLW|CGv&Ke^f-5WD5#J-t$RI zhhHp}=FEPh-duY-Q1~S?A}dj_WzgO*K9#_tsiuTQ?#>rd*!FiVFobP)y uTG%t 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�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%0D|bQyYi1FUWa$pMA`t{XN(L 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 z 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

>pZN<5#cwv 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�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 diff --git a/streamrip/client/__init__.py b/streamrip/client/__init__.py index 8fa610b3..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 @@ -13,4 +13,5 @@ "QobuzClient", "SoundcloudClient", "TidalClient", + "new_session", ] diff --git a/streamrip/client/client.py b/streamrip/client/client.py index cf72a57e..55dd7880 100644 --- a/streamrip/client/client.py +++ b/streamrip/client/client.py @@ -52,24 +52,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, 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} | headers, - connector=connector, - timeout=REQUEST_TIMEOUT, - trust_env=True, - ) +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 e01b3574..d9d23155 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -10,7 +10,7 @@ MissingCredentialsError, NonStreamableError, ) -from .client import Client +from .client import Client, new_session from .downloadable import DeezerDownloadable logger = logging.getLogger("streamrip") @@ -26,7 +26,6 @@ class DeezerClient(Client): logged_in: True if logged in config: deezer local config session: aiohttp.ClientSession, used only for track downloads not API requests - logged_in_user_id: USER_ID of the authenticated account, set during login max_favorites: upper bound for favorites pagination """ @@ -41,7 +40,6 @@ def __init__(self, config: Config): self.logged_in = False self.config = config.session.deezer self._album_cache = {} - self.logged_in_user_id: int | None = None self._quality_warned = False # Increase the deezer-py requests session pool well above max_connections. @@ -62,7 +60,7 @@ def __init__(self, config: Config): 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 @@ -72,12 +70,6 @@ async def login(self): if not success: raise AuthenticationError("Invalid or expired Deezer ARL.") self.logged_in = True - try: - user_data = await asyncio.to_thread(self.client.gw.get_user_data) - self.logged_in_user_id = user_data.get("USER", {}).get("USER_ID") - except Exception as e: - # Only needed for loved-tracks URLs; never worth failing the login. - logger.debug("Could not fetch Deezer user data: %s", e) async def get_metadata(self, item_id: str, media_type: str) -> dict: # TODO: open asyncio PR to deezer py and integrate @@ -214,11 +206,11 @@ async def get_user_favorites(self, user_id: str) -> dict: 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 - ``logged_in_user_id`` to detect "other user" is unreliable for family + 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 + ARL. The ``user_id`` argument is accepted for URL-routing compatibility but is not forwarded to the GW call. Args: @@ -257,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: @@ -265,7 +256,7 @@ 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}") diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 2ff384ea..fa341674 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -3,7 +3,6 @@ import functools import hashlib import itertools -import json import logging import os import re @@ -30,6 +29,8 @@ 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): @@ -203,21 +204,17 @@ 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.") @@ -237,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): @@ -499,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 @@ -510,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": @@ -600,12 +605,19 @@ async def concat_audio_files(paths: list[str], out: str, ext: str, max_files_ope 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 734e001c..f946899a 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -7,8 +7,6 @@ from collections import OrderedDict from typing import List, Optional -import aiohttp - from ..config import Config from ..exceptions import ( APIError, @@ -18,7 +16,7 @@ MissingCredentialsError, NonStreamableError, ) -from .client import Client +from .client import Client, new_session from .downloadable import BasicDownloadable, Downloadable logger = logging.getLogger("streamrip") @@ -42,25 +40,6 @@ def file_url_signature( return hashlib.md5(preimage.encode("utf-8")).hexdigest() -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", -} - - class QobuzSpoofer: """Spoofs the information required to stream tracks from Qobuz.""" @@ -143,15 +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, resolver=aiohttp.ThreadedResolver() - ) - - self.session = aiohttp.ClientSession(connector=connector, trust_env=True) + self.session = new_session(verify_ssl=self.verify_ssl) return self async def __aexit__(self, *_): @@ -183,9 +154,7 @@ async def login(self): 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 = await self.get_session( - verify_ssl=self.config.session.downloads.verify_ssl - ) + self.session = new_session(verify_ssl=self.config.session.downloads.verify_ssl) try: await self._login() except BaseException: @@ -411,25 +380,6 @@ 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 # Qobuz has no quality 0 (128 kbps); clamp instead of asserting so diff --git a/streamrip/client/soundcloud.py b/streamrip/client/soundcloud.py index 78c353a1..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,7 +36,7 @@ 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 diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 01524425..a903eeab 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -3,7 +3,6 @@ import json import logging import random -import re import time from json import JSONDecodeError @@ -16,7 +15,7 @@ MissingCredentialsError, NonStreamableError, ) -from .client import Client +from .client import Client, new_session from .downloadable import TidalDASHDownloadable, TidalDownloadable logger = logging.getLogger("streamrip") @@ -33,12 +32,13 @@ def _b64(s: str) -> str: # 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) for hi-res releases, but AAC 320 for -# ordinary lossless releases +# 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 # -# The default gives lossless for everything. Set [tidal] hires_client = true -# in the config to prefer 24-bit instead, or client_id/client_secret to use -# any other pair (e.g. after Tidal revokes one). +# 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=" @@ -47,10 +47,6 @@ def _b64(s: str) -> str: HIRES_CLIENT_SECRET = _b64( "MU5tNUFmREFqeHJnSkZKYktOV0xlQXlLR1ZHbUlOdVhQUExIVlhBdnhBZz0=" ) -STREAM_URL_REGEX = re.compile( - r"#EXT-X-STREAM-INF:BANDWIDTH=\d+,AVERAGE-BANDWIDTH=\d+,CODECS=\"(?!jpeg)[^\"]+\",RESOLUTION=\d+x\d+\n(.+)" -) - QUALITY_MAP = { 0: "LOW", # AAC 1: "HIGH", # AAC @@ -71,6 +67,8 @@ def _b64(s: str) -> str: "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}) @@ -87,6 +85,22 @@ def _retry_after(resp, attempt: int) -> float: return min(seconds, MAX_RETRY_DELAY) +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): """TidalClient.""" @@ -95,21 +109,39 @@ class TidalClient(Client): # 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 - if c.client_id and c.client_secret: - self.client_id, self.client_secret = c.client_id, c.client_secret - elif c.hires_client: + # 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.client_id = DEFAULT_CLIENT_ID - self.client_secret = DEFAULT_CLIENT_SECRET + 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() @@ -117,36 +149,60 @@ def __init__(self, config: Config): "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: + """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 + + 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 c.token_client_id != self.client_id: + 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 uses one that serves lossless tracks as FLAC " - "instead of AAC, or the config changed it). Please log in again." + "(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(c.token_expiry) + self.token_expiry = float(t.token_expiry) except TypeError, ValueError: self.token_expiry = 0.0 - self.refresh_token = c.refresh_token + 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 @@ -169,21 +225,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( @@ -226,6 +273,61 @@ async def get_metadata(self, item_id: str, media_type: str) -> dict: 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. @@ -248,8 +350,7 @@ async def search(self, media_type: str, query: str, limit: int = 100) -> list[di return [resp] return [] - async def get_downloadable(self, track_id: str, quality: int): - quality = max(0, min(quality, self.max_quality)) + async def _playback_info(self, track_id: str, quality: int) -> dict: params = { "audioquality": QUALITY_MAP[quality], "playbackmode": "STREAM", @@ -263,6 +364,42 @@ async def get_downloadable(self, track_id: str, quality: int): 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 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) @@ -279,7 +416,7 @@ async def get_downloadable(self, track_id: str, quality: int): # 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") == "application/dash+xml": + if resp.get("manifestMimeType") == DASH_MIME: return self._get_downloadable_from_dash(track_id, resp) try: @@ -356,32 +493,6 @@ def _get_downloadable_from_dash(self, track_id: str, resp: dict): codec=codecs, ) - async def get_video_file_url(self, video_id: str) -> str: - """Get the HLS video stream url. - - The stream is downloaded using ffmpeg for now. - - :param video_id: - :type video_id: str - :rtype: str - """ - 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.text(encoding="utf-8") - - # Highest resolution is last - *_, last_match = STREAM_URL_REGEX.finditer(available_urls) - - return last_match.group(1) - # ---------- Login Utilities --------------- async def _login_by_access_token(self, token: str, user_id: str): @@ -392,7 +503,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, @@ -408,25 +519,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": self.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]]: @@ -480,23 +578,19 @@ async def _refresh_access_token(self): "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"): - c.refresh_token = resp["refresh_token"] + t.refresh_token = resp["refresh_token"] # Persist the refreshed token, otherwise every later run refreshes again. - f = self.global_config.file - f.tidal.access_token = c.access_token - f.tidal.token_expiry = c.token_expiry - f.tidal.refresh_token = c.refresh_token - f.set_modified() + 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 getattr(self, "session", None) is None or self.session.closed: - self.session = await self.get_session( + self.session = new_session( verify_ssl=self.global_config.session.downloads.verify_ssl ) @@ -545,7 +639,7 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: attempt = 0 while True: attempt += 1 - if (pause := self._retry_at - time.monotonic()) > 0: + if (pause := self._root._retry_at - time.monotonic()) > 0: await asyncio.sleep(pause) delay = 0.0 @@ -592,9 +686,11 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: 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 self._retry_at <= now: + if root._retry_at <= now: logger.warning( f"Tidal is rate limiting us (HTTP 429); pausing for {seconds:.0f}s" ) - self._retry_at = max(self._retry_at, now + seconds) + root._retry_at = max(root._retry_at, now + seconds) diff --git a/streamrip/config.py b/streamrip/config.py index e40c54f2..3f1f6bff 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,93 +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.3.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 `streamrip config --tidal` token_expiry: str - # 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 FLAC, 3: best available (24-bit - # FLAC where the client id is allowed to stream it) quality: int - # This will download videos included in Video Albums. - download_videos: bool - # Use the OAuth client that is served 24-bit hi-res FLAC. It gets AAC for - # ordinary lossless releases, so the default client (FLAC 16/44.1 for - # everything) is usually the better choice. Only matters with quality = 3. - # Changing it requires logging in again. hires_client: bool = False - # Override the OAuth client id/secret entirely (empty = built-in client). client_id: str = "" client_secret: str = "" - # The client id the saved tokens were issued to. Do not change. 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 streamrip 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) @@ -117,130 +85,67 @@ class DatabaseConfig: @dataclass(slots=True) class ConversionConfig: enabled: bool - # FLAC, ALAC, AIFF, 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] - # When an album or playlist contains both a clean and an explicit copy of - # the same track (same title and artist), skip the clean one and keep - # only the explicit copy. Off by default since it costs an extra API call - # per track (every track has to be resolved before any of them download, - # instead of downloading as each one resolves). prefer_explicit: bool = False @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 - # Download and embed lyrics (currently supported for Deezer and Tidal) - # Defaulted so configs written before this option don't fail to load 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 @@ -254,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" @@ -265,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 @@ -295,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): @@ -344,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): @@ -406,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)) @@ -445,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]: @@ -479,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 1121934a..0e1fccd9 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -5,14 +5,9 @@ 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. Downloading many albums at once (e.g. an artist's @@ -28,7 +23,7 @@ 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 @@ -51,18 +46,16 @@ secrets = [] [tidal] # 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 2, not 3: most tracks don't have a hi-res master, so requesting -# 3 for everything mostly just downloads the same 16/44.1 FLAC anyway. -# Set to 3 (with hires_client = true below) if you want hi-res where it exists. -quality = 2 -# This will download videos included in Video Albums. -download_videos = true -# Tidal decides per OAuth client which formats it will stream. The default -# client gets FLAC 16/44.1 for every lossless release but never hi-res. -# Set this to true to use the client that gets 24-bit FLAC for hi-res releases -# (but AAC 320 for ordinary lossless ones). It only matters together with -# quality = 3: at 2, hi-res is never requested. Changing it requires a new login. -hires_client = false +# Default is 3: hi-res wherever Tidal has a hi-res master. Set it to 2 for +# 16/44.1 FLAC only. +quality = 3 +# 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 = "" @@ -73,46 +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 `streamrip 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 streamrip 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 @@ -139,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 @@ -199,8 +180,8 @@ folder_format = "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{s 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 @@ -212,8 +193,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 @@ -221,6 +200,6 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.3.0" +version = "2.3.3" # Print a message if a new version of streamrip is available check_for_updates = true diff --git a/streamrip/converter.py b/streamrip/converter.py index f358cccb..808180e9 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -9,7 +9,7 @@ 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 @@ -22,7 +22,6 @@ class Converter: """Base class for audio codecs.""" - codec_name: str codec_lib: str container: str lossless: bool = False @@ -34,27 +33,15 @@ class Converter: 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. """ self.ffmpeg_path = find_ffmpeg() if self.ffmpeg_path is None: @@ -75,56 +62,37 @@ def __init__( 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.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 self.copy_art and not type(self)._ffmpeg_supports_art: + if not self._ffmpeg_supports_art: cover_data = await asyncio.to_thread(self._read_source_cover) - self.command = self._gen_command() - logger.debug("Generated conversion command: %s", self.command) - + 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) + _, 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:]}" + ) - if cover_data is not None: - await asyncio.to_thread(self._embed_cover_art, *cover_data) - else: - raise ConversionError(f"FFmpeg output:\n{out, err}") + 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.""" @@ -175,79 +143,31 @@ def _embed_cover_art(self, cover_data: bytes, mime: str) -> None: except Exception as e: logger.warning("Could not embed cover art into %s: %s", self.final_fn, e) - def _gen_command(self): - command = [ - self.ffmpeg_path, - "-i", - self.filename, - ] - - if logger.getEffectiveLevel() != logging.DEBUG: - command.extend(("-loglevel", "panic")) - - command.extend(("-c:a", self.get_codec_lib())) - - if self.show_progress: - command.append("-stats") - - if self.copy_art and type(self)._ffmpeg_supports_art: - command.extend(["-c:v", "copy"]) - elif not type(self)._ffmpeg_supports_art: - command.append("-vn") - - 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 - - @classmethod - def get_codec_lib(cls) -> str: - return cls.codec_lib - - 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", - ) - self.ffmpeg_arg = self.default_ffmpeg_arg - return + 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: @@ -257,7 +177,6 @@ def get_quality_arg(cls, _: int) -> str: class FLAC(Converter): """Class for FLAC converter.""" - codec_name = "flac" codec_lib = "flac" container = "flac" lossless = True @@ -286,7 +205,6 @@ class LAME(Converter): 65: "-q:a 9", } - codec_name = "lame" codec_lib = "libmp3lame" container = "mp3" default_ffmpeg_arg = "-q:a 0" # V0 @@ -299,7 +217,6 @@ def get_quality_arg(cls, rate): class ALAC(Converter): """Class for ALAC converter.""" - codec_name = "alac" codec_lib = "alac" container = "m4a" lossless = True @@ -314,7 +231,6 @@ 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. @@ -341,7 +257,6 @@ 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. @@ -366,13 +281,11 @@ 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" - @classmethod - def get_codec_lib(cls) -> str: + def get_codec_lib(self) -> str: return "libfdk_aac" if _ffmpeg_has_encoder("libfdk_aac") else "aac" @classmethod @@ -383,7 +296,6 @@ def get_quality_arg(cls, rate: int) -> str: class AIFF(Converter): """Class for AIFF converter (uncompressed PCM, upstream PR #1006).""" - codec_name = "aiff" codec_lib = "pcm_s24be" container = "aiff" lossless = True @@ -394,9 +306,6 @@ def __init__(self, *args, **kwargs): if self.bit_depth == 16: self.codec_lib = "pcm_s16be" - def get_codec_lib(self) -> str: # type: ignore[override] - return self.codec_lib - @functools.cache def _ffmpeg_has_encoder(name: str) -> bool: diff --git a/streamrip/db.py b/streamrip/db.py index 2abcfba6..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. @@ -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}" @@ -157,13 +125,6 @@ def all(self): 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.""" @@ -187,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) @@ -196,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 09af0f70..7b742399 100644 --- a/streamrip/exceptions.py +++ b/streamrip/exceptions.py @@ -11,13 +11,6 @@ class MissingCredentialsError(Exception): """MissingCredentials.""" -class IneligibleError(Exception): - """IneligibleError. - - Raised when the account is not eligible to stream a track. - """ - - class InvalidAppIdError(Exception): """InvalidAppIdError.""" diff --git a/streamrip/media/album.py b/streamrip/media/album.py index 20db72fd..6aae08be 100644 --- a/streamrip/media/album.py +++ b/streamrip/media/album.py @@ -1,19 +1,18 @@ -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 ..db import Database -from ..exceptions import NonStreamableError, TrackDownloadFailedError -from ..filepath_utils import clean_filepath +from ..exceptions import NonStreamableError +from ..filepath_utils import clean_filename from ..metadata import AlbumMetadata from ..metadata.util import get_album_track_ids from .artwork import download_artwork -from .media import Media, Pending, filter_prefer_explicit, resolve_or_none -from .track import PendingTrack +from .media import Media, Pending, rip_tracks +from .track import PendingTrack, album_folder logger = logging.getLogger("streamrip") @@ -21,6 +20,27 @@ RESOLVE_CONCURRENCY = 4 +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): meta: AlbumMetadata @@ -29,69 +49,30 @@ 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) + # 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 + ) async def download(self): - if self.config.session.metadata.prefer_explicit: - await self._resolve_then_download() - return - - resolve_slots = asyncio.Semaphore(RESOLVE_CONCURRENCY) - - async def _resolve_and_download(pending: Pending): - try: - async with resolve_slots: - track = await pending.resolve() - if track is None: - return - 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, - # which used to log as "Error downloading track: ''" (#938). - logger.error(f"Error downloading track: {type(e).__name__}: {e}") - - results = await asyncio.gather( - *[_resolve_and_download(p) for p in self.tracks], return_exceptions=True + await rip_tracks( + self.tracks, + RESOLVE_CONCURRENCY, + self.config.session.metadata.prefer_explicit, ) - for result in results: - if isinstance(result, Exception): - logger.error(f"Album track processing error: {result}") - - async def _resolve_then_download(self): - """Resolve every track before downloading any of them, so a clean - copy can be dropped in favor of an explicit one once both are known - (see [metadata] prefer_explicit). Costs one extra API call per track - compared to the default resolve-and-download-immediately path. - """ - resolve_slots = asyncio.Semaphore(RESOLVE_CONCURRENCY) - - async def _resolve(pending: Pending): - async with resolve_slots: - return await resolve_or_none(pending) - - resolved = await asyncio.gather(*[_resolve(p) for p in self.tracks]) - tracks = filter_prefer_explicit([t for t in resolved if t is not None]) - - async def _download(track): - try: - await track.rip() - except TrackDownloadFailedError: - pass - except Exception as e: - logger.error(f"Error downloading track: {type(e).__name__}: {e}") - - results = await asyncio.gather( - *[_download(t) for t in tracks], return_exceptions=True - ) - for result in results: - if isinstance(result, Exception): - logger.error(f"Album track processing error: {result}") - async def postprocess(self): progress.remove_title(self.meta.album) @@ -115,7 +96,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: @@ -137,14 +118,13 @@ async def resolve(self) -> Album | None: f"{meta.album}: skipping {done} of {len(tracklist)} tracks " "already downloaded" ) - folder = self.config.session.downloads.folder - album_folder = self._album_folder(folder, meta) + folder = album_folder(self.config, self.client.source, meta) if tracklist and not todo: - return Album(meta, [], self.config, album_folder, self.db) - os.makedirs(album_folder, exist_ok=True) + 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, @@ -155,22 +135,11 @@ async def resolve(self) -> Album | None: album=meta, client=self.client, config=self.config, - folder=album_folder, + folder=folder, db=self.db, cover_path=embed_cover, ) 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 91a4df7a..1ef21d1e 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 @@ -21,9 +22,28 @@ RESOLVE_CHUNK_SIZE = 4 +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] @@ -42,7 +62,7 @@ async def download(self): f"[bold]{self.name}[/bold]: found {len(self.albums)} release(s), " "resolving and downloading..." ) - filter_conf = self.config.session.qobuz_filters + 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." @@ -54,131 +74,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. Resolving still needs every - album's title before filtering can happen, but that doesn't mean - firing every resolve request at once -- chunk it the same as the - download phase below, so this path doesn't burst past the streaming - service's rate limit either. + 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: list[Album] = [] - for chunk in self.batch(self.albums, RESOLVE_CHUNK_SIZE): - resolved_or_none: list[Album | None] = await asyncio.gather( - *[album.resolve() for album in chunk] - ) - resolved.extend(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): - # 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 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: - 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() except Exception as e: logger.error(f"Error downloading album: {type(e).__name__}: {e}") - await asyncio.gather(*[_rip(album) for album in self.albums]) - - 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]: + 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)) + ) + + @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)?") @@ -188,13 +170,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 59b80d1d..08b6e3d6 100644 --- a/streamrip/media/label.py +++ b/streamrip/media/label.py @@ -1,15 +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") @@ -17,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] @@ -35,32 +34,11 @@ async def download(self): f"[bold]{self.name}[/bold]: found {len(self.albums)} release(s), " "resolving and downloading..." ) - # Resolve only a few albums at a time to avoid the initial latency - # of resolving ALL albums and tracks before any downloads start. - album_resolve_chunk_size = 4 - - 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, - ) - 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 c780a691..eeeeb05a 100644 --- a/streamrip/media/media.py +++ b/streamrip/media/media.py @@ -1,6 +1,9 @@ +import asyncio import logging from abc import ABC, abstractmethod +from ..exceptions import TrackDownloadFailedError + logger = logging.getLogger("streamrip") @@ -40,7 +43,8 @@ async def resolve_or_none(pending: Pending) -> Media | None: try: return await pending.resolve() except Exception as e: - logger.error(f"Error resolving track: {type(e).__name__}: {e}") + item = getattr(pending, "id", "") + logger.error(f"Error resolving {item}: {type(e).__name__}: {e}") return None @@ -64,3 +68,39 @@ def filter_prefer_explicit(tracks: list) -> list: 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 5e6e0ccd..11bf3cd4 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, TrackDownloadFailedError -from ..filepath_utils import clean_filename, clean_filepath -from ..metadata import ( - AlbumMetadata, - Covers, - PlaylistMetadata, - SearchResults, - TrackMetadata, -) -from ..utils.ssl_utils import get_aiohttp_connector_kwargs +from ..exceptions import NonStreamableError +from ..filepath_utils import clean_filename +from ..metadata import PlaylistMetadata, SearchResults from .artwork import download_artwork -from .media import Media, Pending, filter_prefer_explicit, resolve_or_none -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) @@ -118,75 +94,12 @@ async def postprocess(self): progress.remove_title(self.name) async def download(self): - if self.config.session.metadata.prefer_explicit: - await self._resolve_then_download() - return - - 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 TrackDownloadFailedError: - pass # already logged and recorded by Track.download() - except Exception as e: - # Include the type: some exceptions have an empty message, - # which used to log as "Error downloading track: ''" (#938). - logger.error(f"Error downloading track: {type(e).__name__}: {e}") - - batches = self.batch( - [_resolve_download(track) for track in self.tracks], - track_resolve_chunk_size, + await rip_tracks( + self.tracks, + RESOLVE_CONCURRENCY, + self.config.session.metadata.prefer_explicit, ) - 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}") - - async def _resolve_then_download(self): - """Resolve every track before downloading any of them, so a clean - copy can be dropped in favor of an explicit one once both are known - (see [metadata] prefer_explicit). Costs one extra API call per track - compared to the default resolve-and-download-immediately path. - """ - track_resolve_chunk_size = 20 - resolved = [] - for chunk in self.batch(self.tracks, track_resolve_chunk_size): - resolved_or_none = await asyncio.gather( - *[resolve_or_none(t) for t in chunk] - ) - resolved.extend(t for t in resolved_or_none if t is not None) - - tracks = filter_prefer_explicit(resolved) - - async def _download(track): - try: - await track.rip() - except TrackDownloadFailedError: - pass - except Exception as e: - logger.error(f"Error downloading track: {type(e).__name__}: {e}") - - for chunk in self.batch(tracks, track_resolve_chunk_size): - results = await asyncio.gather( - *[_download(t) for t in chunk], 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)] - @dataclass(slots=True) class PendingPlaylist(Pending): @@ -210,8 +123,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(clean_filename(name))) + folder = _playlist_folder(self.config, name) + ids = meta.ids() tracks = [ PendingPlaylistTrack( id, @@ -219,10 +132,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) @@ -258,32 +172,24 @@ 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(clean_filename(playlist_title))) + folder = _playlist_folder(self.config, playlist_title) pending_tracks = [] for pos, (id, from_fallback) in enumerate(results, start=1): @@ -306,64 +212,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, @@ -398,16 +283,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, resolver=aiohttp.ThreadedResolver() - ) - - async with aiohttp.ClientSession( - connector=connector, trust_env=True - ) 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: @@ -438,17 +317,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 82e33f8f..0faa10a3 100644 --- a/streamrip/media/semaphore.py +++ b/streamrip/media/semaphore.py @@ -8,27 +8,16 @@ 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)) diff --git a/streamrip/media/track.py b/streamrip/media/track.py index fa498ce5..ce9cf771 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -3,13 +3,17 @@ 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, TrackDownloadFailedError -from ..filepath_utils import clean_filename, fit_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 ..progress import add_title, get_progress_callback, remove_title from .artwork import download_artwork @@ -21,10 +25,26 @@ # One try plus three retries with exponential backoff. MAX_DOWNLOAD_ATTEMPTS = 4 -# FLAC is always preferred over a lossy container for the same track: if one -# is already on disk, there's no reason to also keep (or fetch) the other. -LOSSLESS_EXTENSIONS = {"flac", "aiff", "aif"} -LOSSY_EXTENSIONS = {"m4a", "mp3"} +# 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) @@ -36,7 +56,6 @@ 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 @@ -50,7 +69,9 @@ class Track(Media): async def preprocess(self): self._set_download_path() os.makedirs(self.folder, exist_ok=True) - if self._lossless_duplicate_exists(): + 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, " @@ -132,13 +153,8 @@ async def postprocess(self): f"Could not convert '{self.meta.title}', keeping the " f"original file: {type(e).__name__}: {e}" ) - else: - # Conversion picks its own target codec for every track, so a - # mismatched sibling from a previous run doesn't mean anything - # there; only clean up when this file's own format is the one on - # disk. - self._remove_lossy_duplicates() + self._remove_lossy_copies() self.db.set_downloaded(self.meta.info.id) async def _convert(self): @@ -187,26 +203,115 @@ 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 _lossless_duplicate_exists(self) -> bool: - if self.downloadable.extension.lower() not in LOSSY_EXTENSIONS: - return False - return any( - os.path.isfile(self._sibling_path(ext)) for ext in LOSSLESS_EXTENSIONS - ) + def _copies_on_disk(self, lossless: bool) -> list[str]: + """Lossless (or lossy) files of this same track under another extension. - def _remove_lossy_duplicates(self): - final_ext = self.download_path.rsplit(".", 1)[-1].lower() - if final_ext not in LOSSLESS_EXTENSIONS: + 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 ext in LOSSY_EXTENSIONS: - sibling = self._sibling_path(ext) - if os.path.isfile(sibling): - logger.info(f"Removing lower-quality duplicate: {sibling}") - os.remove(sibling) + 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 @@ -217,68 +322,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 - # Every failure below has to be recorded, not just logged. An unlogged - # failure leaves no trace anywhere: no file, no downloads.db row, and - # nothing in the failed db for `streamrip repair` to retry -- the track just - # silently goes missing from the album. - 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}") - self.db.set_failed(source, "track", self.id) - 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}") - self.db.set_failed(source, "track", self.id) - 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}" - ) - self.db.set_failed(source, "track", self.id) - 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 @@ -287,106 +351,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 - - # As in PendingTrack.resolve: record every failure, so a track that - # dies here is retryable by `streamrip repair` instead of vanishing. - try: - resp = await self.client.get_metadata(self.id, "track") - except NonStreamableError as e: - logger.error(f"Error fetching track {self.id}: {e}") - self.db.set_failed(self.client.source, "track", self.id) - 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}") - self.db.set_failed(self.client.source, "track", self.id) - 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}") - self.db.set_failed(self.client.source, "track", self.id) - 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 - in_album_folder = config.filepaths.add_singles_to_folder + album, c = meta.album, self.config.session + in_album_folder = c.filepaths.add_singles_to_folder if in_album_folder: - album_folder = self._format_folder(album) + folder = album_folder(self.config, self.client.source, album) else: - album_folder = parent - - os.makedirs(album_folder, exist_ok=True) - - embedded_cover_path, downloadable = await asyncio.gather( - self._download_cover(album.covers, album_folder), - self.client.get_downloadable(self.id, quality), + folder = c.downloads.folder + os.makedirs(folder, exist_ok=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 - # Mirror PendingTrack: a track belonging to a multi-disc album lives in - # that album's disc subfolder. Without this, downloading one track of a - # multi-disc album (`streamrip repair`, or any single-track URL) drops it - # beside the Disc folders rather than into the one it belongs to. - # Only meaningful when we're actually building the album's folder -- - # otherwise this would create a bare "Disc N" in the download root. - folder = album_folder - if ( - in_album_folder - and config.downloads.disc_subdirectories - and album.disctotal > 1 - ): - folder = os.path.join(album_folder, f"Disc {meta.discnumber}") + # 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, - embedded_cover_path, - self.db, - is_single=True, - ) - - 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, + meta, downloadable, self.config, folder, cover_path, self.db, is_single=True ) - return embed_path diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index 8374d1a9..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, tidal_quality_id, 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,11 @@ 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 @@ -98,418 +98,190 @@ def from_qobuz(cls, resp: dict) -> AlbumMetadata: # 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})" - tracktotal = resp.get("tracks_count", 1) - genre_name = safe_get(resp, "genre", "name") - genre = [genre_name] if isinstance(genre_name, str) else [] - genres = list(set(genre_clean.findall("/".join(genre)))) + 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") or "Unknown Artist", str - ) + 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) - 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( + info = AlbumInfo( + 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=resp.get("goodies") or None, + ) + return cls( + info, + album, + albumartist, + _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. - resp.get("media_count") + 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, - int, - ) - explicit = typed(resp.get("parental_warning", False), bool) - - # Non-embedded information - cover_urls = Covers.from_qobuz(resp) - - 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, - sampling_rate=sampling_rate, - bit_depth=bit_depth, - booklets=booklets, - ) - return AlbumMetadata( - info, - album, - albumartist, - year, - genre=genres, - covers=cover_urls, - albumcomposer=albumcomposer, - comment=None, - compilation=None, - copyright=_copyright, + 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) if resp["tracks"] else 1 - ) - genres = [ - typed(g["name"], str) - for g in safe_get(resp, "genres", "data", default=[]) or [] - ] - - date = typed(resp.get("release_date") or "Unknown", str) - year = date[:4] - _copyright = None - description = None - contributors = resp.get("contributors", []) - albumartist = ", ".join( - c["name"] for c in contributors if c["type"] == "artist" - ) or 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") or "Unknown", 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 = tidal_quality_id(resp.get("audioQuality", "LOW")) - 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] - else: - year = "Unknown Year" - - _copyright = typed(resp.get("copyright") or "", 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 = tidal_quality_id(resp.get("audioQuality", "LOW")) - if quality >= 2: - sampling_rate = 44100 - if quality == 3: - bit_depth = 24 - else: - bit_depth = 16 + 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: - 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.get("release_date") or "Unknown" - year = date[:4] - albumartist = ", ".join( - a["name"] for a in resp.get("contributors") or [] - ) or typed(safe_get(resp, "artist", "name", default="Unknown Artist"), str) - 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 4cadc210..38751199 100644 --- a/streamrip/metadata/playlist.py +++ b/streamrip/metadata/playlist.py @@ -5,41 +5,9 @@ from .track import TrackMetadata from .util import safe_get, typed -NON_STREAMABLE = "_non_streamable" -ORIGINAL_DOWNLOAD = "_original_download" -NOT_RESOLVED = "_not_resolved" - 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 @@ -104,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/tagger.py b/streamrip/metadata/tagger.py index eb6354de..82b51354 100644 --- a/streamrip/metadata/tagger.py +++ b/streamrip/metadata/tagger.py @@ -19,87 +19,97 @@ 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, -) +# 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, +} -METADATA_TYPES = ( - "title", - "artist", - "album", - "albumartist", - "composer", - "year", - "comment", - "description", - "purchase_date", - "grouping", - "genre", - "lyrics", - "encoder", - "copyright", - "compilation", - "tracknumber", - "discnumber", - "tracktotal", - "disctotal", - "date", - "isrc", - # Keep "version" last: MP3_KEY/MP4_KEY zip this tuple against their own - # positional key tuples, so a trailing entry with no counterpart is simply - # dropped for those formats. FLAC_KEY is built by comprehension and picks - # it up as the standard Vorbis VERSION field. - "version", -) +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", +} -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)) +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): @@ -127,115 +137,27 @@ def get_mutagen_class(self, path: str): return {} def get_tag_pairs(self, meta, exclude=()) -> list[tuple]: - if self == Container.FLAC: - pairs = self._tag_flac(meta) - key_map = FLAC_KEY - elif self in (Container.MP3, Container.AIFF): - pairs = self._tag_mp3(meta) - key_map = {k: v.__name__ for k, v in MP3_KEY.items() if v is not None} - elif self == Container.AAC: - pairs = self._tag_mp4(meta) - key_map = MP4_KEY - else: - return [] # [metadata] exclude lists streamrip's own tag names ("genre", # "albumartist", ...); it used to be ignored entirely (upstream #850). - excluded = {key_map[name] for name in exclude or () if key_map.get(name)} - return [(k, v) for k, v in pairs if k not in excluded] - - def _tag_flac(self, meta: TrackMetadata) -> list[tuple]: - out = [] - for k, v in FLAC_KEY.items(): - if k == "artist" and meta.artists and len(meta.artists) > 1: - # Multiple separate ARTIST fields (Vorbis comments support - # repeated keys) instead of one "A, B" string, so players - # don't have to guess where to split it. - out.append((v, meta.artists)) - continue - - 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}" - elif k == "artist" and meta.artists and len(meta.artists) > 1: - # A real multi-value TPE1; save_audio()'s update_to_v23() + - # v2_version=3 joins these with "/" (the separator players - # actually recognize), instead of us baking in ", " which - # is indistinguishable from a comma inside one artist's name. - text = meta.artists - 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") - elif k == "artist" and meta.artists and len(meta.artists) > 1: - # Multiple values under \xa9ART instead of one joined string. - text = meta.artists - 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) + values = {k: v for k, v in _tag_values(meta).items() if k not in exclude} + if self == Container.FLAC: + 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_audio(self, audio, tags: list[tuple]): for k, v in tags: @@ -265,9 +187,7 @@ 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: # ID3v2.3 for the widest player support. This used to pass the diff --git a/streamrip/metadata/track.py b/streamrip/metadata/track.py index 71bc5b02..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, tidal_quality_id, 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) @@ -42,180 +36,78 @@ class TrackMetadata: @classmethod def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None: - title = typed(resp["title"].strip(), str) - isrc = typed(resp.get("isrc"), str | None) - 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) - # "performer" is missing on some tracks (upstream #668); fall back to - # the album artist rather than failing the whole track. - artist = typed( + return cls( + 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, - 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) - explicit = typed(resp.get("parental_warning", False), bool) - - 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, + 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.get("isrc"), str | None) - bit_depth = 16 - sampling_rate = 44.1 - explicit = typed(resp.get("explicit_lyrics", False), bool) - work = None - title = typed(resp["title"], str) - contributors = resp.get("contributors", []) - artist_names = [c["name"] for c in contributors if c["type"] == "artist"] or [ - typed(resp["artist"]["name"], str) - ] - artist = ", ".join(artist_names) - tracknumber = typed(resp["track_position"], int) - discnumber = typed(resp["disk_number"], int) - composer = None - lyrics = resp.get("lyrics", "") - 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, - lyrics=lyrics, - artists=artist_names, + 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.get("isrc"), str | None) - 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) - - track_artists = track.get("artists") or [] - if len(track_artists) > 0: - artist_names = [a["name"] for a in track_artists] - else: - artist_names = [track["artist"]["name"]] - artist = ", ".join(artist_names) - - lyrics = track.get("lyrics", "") - - quality = tidal_quality_id(track.get("audioQuality")) - - 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, - artists=artist_names, + 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 @@ -228,7 +120,7 @@ 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: "id", "tracknumber", "discnumber", "artist", "album", diff --git a/streamrip/metadata/util.py b/streamrip/metadata/util.py index 2dabaed4..4a7b120f 100644 --- a/streamrip/metadata/util.py +++ b/streamrip/metadata/util.py @@ -13,6 +13,13 @@ def get_album_track_ids(source: str, resp) -> list[str]: 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, @@ -51,14 +58,7 @@ 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..5bc6fc5f 100644 --- a/streamrip/progress.py +++ b/streamrip/progress.py @@ -19,7 +19,6 @@ class ProgressManager: def __init__(self): self.started = False - self.progress = Progress(console=console) self.progress = Progress( TextColumn("[cyan]{task.description}"), BarColumn(bar_width=None), diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 809de574..380e4b85 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -19,9 +19,10 @@ 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") @@ -60,6 +61,9 @@ def stop(): 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 @@ -185,7 +189,6 @@ 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 @@ -209,41 +212,32 @@ 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() - - 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)) + 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 - except aiohttp.ClientConnectorCertificateError as e: - from ..utils.ssl_utils import print_ssl_error_help + async with Main(cfg) as main: + await main.add_all(urls) + await main.resolve() + await main.rip() - console.print(f"[red]SSL Certificate verification error: {e}[/red]") - print_ssl_error_help() + 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)) @rip.command() @@ -263,43 +257,39 @@ async def file(ctx, path): """ if ctx.obj["config"] is None: return - 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) + 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() @@ -358,45 +348,36 @@ 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: - console.print( - f"[red]Invalid database[/red] [bold]{table}[/bold]. [red]Choose[/red] [bold]downloads " - "[red]or[/red] failed[/bold].", + 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") @@ -561,10 +542,9 @@ async def repair(ctx, yes, flat): await main.rip() # Nothing in the download pipeline removes rows from the failed db, so - # success can't be detected by diffing it. Instead rely on the - # invariant this patch establishes: set_downloaded() is only reached - # via postprocess(), which a failed download never gets to. So an item - # present in the downloads db now is one that just succeeded. + # 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 @@ -599,8 +579,8 @@ async def repair(ctx, yes, flat): @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) @@ -621,15 +601,16 @@ async def search(ctx, first, output_file, num_results, source, media_type, query 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() @@ -704,14 +685,8 @@ async def latest_streamrip_version(verify_ssl: bool = True) -> tuple[str, str | A tuple of (version, release_notes) """ try: - connector_kwargs = get_aiohttp_connector_kwargs(verify_ssl=verify_ssl) - connector = aiohttp.TCPConnector( - **connector_kwargs, resolver=aiohttp.ThreadedResolver() - ) timeout = aiohttp.ClientTimeout(total=10) - async with aiohttp.ClientSession( - connector=connector, trust_env=True, timeout=timeout - ) as s: + 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"}, diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 780f8839..676be19b 100644 --- a/streamrip/rip/main.py +++ b/streamrip/rip/main.py @@ -23,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 @@ -187,10 +188,8 @@ async def _reauthenticate(self, source: str, prompter, cause: Exception): 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() @@ -215,19 +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"): try: - pages = await client.search(media_type, query, limit=100) + pages = await client.search(media_type, query, limit=limit) except APIError as e: console.print(f"[red]Search failed: {e}") - return - 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) + 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 @@ -272,39 +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"): - try: - pages = await client.search(media_type, query, limit=1) - except APIError as e: - console.print(f"[red]Search failed: {e}") - return - - 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"): - try: - pages = await client.search(media_type, query, limit=limit) - except APIError as e: - console.print(f"[red]Search failed: {e}") - return - - 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) @@ -341,7 +330,9 @@ 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 diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index cd16f3fd..46fd2b8a 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -22,11 +22,9 @@ ) 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): @@ -276,6 +274,5 @@ def parse_url(url: str) -> URL | None: SoundcloudURL.from_str(url), DeezerDynamicURL.from_str(url), DeezerFavoriteURL.from_str(url), - # TODO: the rest of the url types ] 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 4cba4dce..b5006aba 100644 --- a/streamrip/rip/prompter.py +++ b/streamrip/rip/prompter.py @@ -148,10 +148,28 @@ 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() + """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( @@ -169,7 +187,7 @@ async def prompt_and_login(self): while True: if time.time() - start > self.timeout_s: raise AuthenticationError("Timed out waiting for the Tidal login.") - status, info = await self.client._get_auth_status(device_code) + status, info = await lane._get_auth_status(device_code) if status == 2: # pending await asyncio.sleep(4) @@ -185,14 +203,14 @@ async def prompt_and_login(self): 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 - c.token_client_id = self.client.client_id + 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) @@ -203,10 +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 - cf.token_client_id = c.token_client_id + for lane in self.client.lanes(): + lane.save_login() self.config.file.set_modified() diff --git a/streamrip/utils/ssl_utils.py b/streamrip/utils/ssl_utils.py index 9f05ef36..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 {"ssl": False} - - if HAS_CERTIFI: - ssl_context = create_ssl_context(verify=True) - return {"ssl": ssl_context} - else: - return {"ssl": True} + if certifi is not None: + return {"ssl": ssl.create_default_context(cafile=certifi.where())} + return {"ssl": True} def print_ssl_error_help(): diff --git a/tests/silence.m4a b/tests/silence.m4a new file mode 100644 index 0000000000000000000000000000000000000000..43b83709191e36c5825b823079ba87568d006655 GIT binary patch literal 826 zcmbVJJ5Iwu5S@(^ND)wkKp^D8NJ$3`3Q!>hi6RXZf&xL|WH(=LYOD zI0H98;uhhJfR2Lo=&DLRYTt& zh$Okv=a0S>C9Q#ks{&(FDJZaRKAl-SYuWR`d0MXC{pFJAYdH=8|k35X6X-|Wmc-^}h1QPj5&%XCpXsf$c{Pd3E%8SmjDMV5|9Z0Y1q544-922?~4P9dGg2=|b<*R>z z3JN}LE{4c=?a=kCmbhLGeODH^;6|T6`Zkrc1`?4<&GO&eq+WL^sI*K3II)(SZ-2ce zI-A|i-4G`OndZefCYy{3pYaxV*%l+2D&_>CPDXt=q0wuiO}UImGFO~u<1w};c^Z5F z&Di@Ce#cl-EQ2|1k%}IW&5iCoER~2yg*8_kgu|o8j$_6(YRp@n6&ve5h51+n;p3eI znAI8X5&D+CvXif@p2-q0A6V2kkg0d55*F4F&^`~ Jf2^KhYu^zaUWxz! literal 0 HcmV?d00001 diff --git a/tests/test_album.py b/tests/test_album.py index 69bb8b4d..b50d519f 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -81,7 +81,7 @@ def _pending_album(monkeypatch, tmp_path, tracklist, downloaded): ) monkeypatch.setattr(album_module, "download_artwork", artwork) monkeypatch.setattr( - PendingAlbum, "_album_folder", lambda self, parent, m: str(tmp_path / "Encore") + album_module, "album_folder", lambda config, source, m: str(tmp_path / "Encore") ) return PendingAlbum("1", client, config, db), artwork @@ -124,3 +124,78 @@ async def test_new_album_logs_nothing_about_skipping(monkeypatch, tmp_path, capl 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 index 7eea9f66..575ab599 100644 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -2,8 +2,9 @@ import pytest -from streamrip.config import QobuzDiscographyFilterConfig +from streamrip.config import ArtistFilterConfig from streamrip.media.artist import RESOLVE_CHUNK_SIZE, Artist +from streamrip.media.label import Label class _FakeAlbum: @@ -32,12 +33,11 @@ async def resolve(self): return _FakeAlbum() -NO_FILTERS = QobuzDiscographyFilterConfig( +NO_FILTERS = ArtistFilterConfig( extras=False, repeats=False, non_albums=False, features=False, - non_studio_albums=False, non_remaster=False, ) @@ -110,7 +110,7 @@ async def resolve(self): @pytest.mark.asyncio async def test_resolve_then_download_chunks_the_resolve_phase(): - """Used when qobuz_filters.repeats is on: resolving every album's title + """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 @@ -121,3 +121,64 @@ async def test_resolve_then_download_chunks_the_resolve_phase(): 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 index 463b2f3c..35bfb5b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -116,3 +116,53 @@ 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 dc1a4641..e9c35203 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.3.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,7 +158,6 @@ 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, @@ -182,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( @@ -221,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, ), @@ -255,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 @@ -309,3 +294,66 @@ def test_prefer_explicit_missing_from_toml_still_loads(): 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 a88f5bed..e60523be 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 @@ -43,8 +38,6 @@ 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 = "" @@ -55,15 +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 `streamrip 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 @@ -71,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 streamrip 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 @@ -119,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 @@ -178,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 @@ -191,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 @@ -200,5 +176,5 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.3.0" +version = "2.3.3" check_for_updates = true diff --git a/tests/test_converter.py b/tests/test_converter.py index 156921ce..d164bb54 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,8 +1,15 @@ +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: @@ -69,3 +76,49 @@ def test_lossy_codec_quality_args_use_configured_bitrate(): 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 index 99003b1c..399dcea8 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -19,3 +19,13 @@ def test_a_cleared_database_is_usable_again(tmp_path): 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 674bd33f..c8567c1a 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -1,5 +1,5 @@ import os -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest from util import arun @@ -60,11 +60,10 @@ def test_deezer_fallback_logic_with_mock_data(mock_deezer_client): 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)) + 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 + # 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): @@ -81,11 +80,10 @@ def test_deezer_no_fallback_when_quality_available(mock_deezer_client): 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)) + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) - # Should use requested quality 2 (FLAC) - assert downloadable.quality == 2 + # Should use requested quality 2 (FLAC) + assert downloadable.quality == 2 def test_deezer_fallback_to_lowest_available_quality(mock_deezer_client): @@ -102,11 +100,10 @@ def test_deezer_fallback_to_lowest_available_quality(mock_deezer_client): 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)) + 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 + # 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): @@ -128,9 +125,8 @@ def test_deezer_no_url_raises_instead_of_building_legacy_cdn_url(mock_deezer_cli mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = None - with patch.object(mock_deezer_client, "get_session"): - with pytest.raises(NonStreamableError, match="legacy CDN"): - arun(mock_deezer_client.get_downloadable("123", quality=2)) + 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): @@ -147,9 +143,8 @@ def test_deezer_no_url_error_does_not_leak_a_cdn_url(mock_deezer_client): mock_deezer_client.client.gw.get_track.return_value = mock_track_info mock_deezer_client.client.get_track_url.return_value = None - with patch.object(mock_deezer_client, "get_session"): - with pytest.raises(NonStreamableError) as excinfo: - arun(mock_deezer_client.get_downloadable("123", quality=2)) + with pytest.raises(NonStreamableError) as excinfo: + arun(mock_deezer_client.get_downloadable("123", quality=2)) assert "e-cdns-proxy" not in str(excinfo.value) @@ -184,8 +179,7 @@ def gw_get_track(track_id): None if token == "token_123" else "https://test.flac" ) - with patch.object(mock_deezer_client, "get_session"): - downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + 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. @@ -209,9 +203,8 @@ def test_deezer_fallback_is_not_followed_twice(mock_deezer_client): } mock_deezer_client.client.get_track_url.return_value = None - with patch.object(mock_deezer_client, "get_session"): - with pytest.raises(NonStreamableError, match="delisted"): - arun(mock_deezer_client.get_downloadable("123", quality=2)) + 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 @@ -235,12 +228,11 @@ def test_deezer_no_fallback_when_disabled(mock_deezer_client): 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): 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 452a80e8..8468cc3e 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -107,3 +107,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_media.py b/tests/test_media.py index 9546b95e..747c6bfb 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -15,7 +15,7 @@ class FakeResolvedTrack: """ def __init__(self, title, artist, explicit): - info = TrackInfo(id="1", quality=2, explicit=explicit) + info = TrackInfo(id="1", explicit=explicit) album = AlbumMetadata( AlbumInfo("1", 2, "flac"), "Album", artist, "2020", [], Covers(), 1 ) diff --git a/tests/test_meta.py b/tests/test_meta.py index a02d8966..54014179 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -31,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 @@ -40,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 @@ -52,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 @@ -63,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_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 index 7ca34455..9d015379 100644 --- a/tests/test_qobuz_download_only.py +++ b/tests/test_qobuz_download_only.py @@ -53,7 +53,9 @@ def _login_resp(parameters): def _run_login(monkeypatch, parameters) -> QobuzClient: client = _make_client() - monkeypatch.setattr(client, "get_session", AsyncMock(return_value=_FakeSession())) + monkeypatch.setattr( + "streamrip.client.qobuz.new_session", lambda **_: _FakeSession() + ) monkeypatch.setattr( client, "_api_request", @@ -77,8 +79,7 @@ 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 (no IneligibleError); the account can download - # purchased content. + # Login still succeeds; the account can download purchased content. assert client.logged_in is True diff --git a/tests/test_qobuz_errors.py b/tests/test_qobuz_errors.py index c42f029f..5329fcd5 100644 --- a/tests/test_qobuz_errors.py +++ b/tests/test_qobuz_errors.py @@ -99,7 +99,7 @@ async def test_failed_login_does_not_quote_the_token(): c._api_request = AsyncMock(return_value=(401, {})) session = MagicMock() session.close = AsyncMock() - with patch.object(QobuzClient, "get_session", new=AsyncMock(return_value=session)): + 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) diff --git a/tests/test_soundcloud.py b/tests/test_soundcloud.py index 60e5d177..2ba9e340 100644 --- a/tests/test_soundcloud.py +++ b/tests/test_soundcloud.py @@ -75,3 +75,88 @@ def get(url): 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 4827f00e..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 == {"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 = {"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 = {"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 = {"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 = {"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 afdde2d5..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,21 +86,8 @@ 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) @@ -122,3 +102,60 @@ def test_tag_flac_multiple_artists(sample_metadata): 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 index 1f5f78c4..9eed49c7 100644 --- a/tests/test_tidal.py +++ b/tests/test_tidal.py @@ -13,6 +13,7 @@ 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 = """ @@ -34,26 +35,44 @@ """ -def _client() -> TidalClient: - return TidalClient(Config.defaults()) +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_default_client_is_the_lossless_one(): - assert _client().client_id == DEFAULT_CLIENT_ID +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_default_quality_is_lossless_not_hires(): - # Regression guard: most catalogs mostly don't have a hi-res master, so - # defaulting to 3 (HI_RES) mainly just produces a warning-per-track for - # no benefit -- see the commit that changed this. A future template - # edit reverting to 3 should fail this test, not surface as log noise. - assert Config.defaults().session.tidal.quality == 2 +def test_no_hires_lane_without_hires_client(): + client = _client() + assert client.hires_lane is None + assert client.lanes() == [client] -def test_hires_client_can_be_selected(): +def test_no_hires_login_when_hires_is_not_asked_for(): cfg = Config.defaults() - cfg.session.tidal.hires_client = True - assert TidalClient(cfg).client_id == HIRES_CLIENT_ID + 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(): @@ -65,11 +84,11 @@ def test_explicit_client_override_wins(): @pytest.mark.asyncio -async def test_token_from_other_client_requires_new_login(): +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 - c.get_session = AsyncMock(return_value=MagicMock()) + monkeypatch.setattr("streamrip.client.tidal.new_session", lambda **_: MagicMock()) with pytest.raises(MissingCredentialsError): await c.login() @@ -176,6 +195,169 @@ async def test_no_warning_when_served_quality_matches_or_exceeds_request(caplog) 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() @@ -187,3 +369,93 @@ 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_requests.py b/tests/test_tidal_requests.py index f29233e2..31ca3dfa 100644 --- a/tests/test_tidal_requests.py +++ b/tests/test_tidal_requests.py @@ -7,7 +7,7 @@ import aiohttp import pytest -from streamrip.client.client import Client +from streamrip.client.client import new_session from streamrip.client.tidal import ( MAX_API_ATTEMPTS, MAX_RETRY_DELAY, @@ -166,7 +166,7 @@ async def test_ssl_errors_are_not_retried(sleeps): @pytest.mark.asyncio async def test_sessions_do_not_wait_five_minutes_on_a_stalled_connection(): - session = await Client.get_session() + session = new_session() try: assert session.timeout.total is None assert session.timeout.sock_read == 30 diff --git a/tests/test_track.py b/tests/test_track.py index 5653c7eb..fb840c17 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -1,6 +1,8 @@ import os import shutil +from unittest.mock import AsyncMock, MagicMock +import mutagen import pytest from util import arun @@ -8,7 +10,8 @@ from streamrip.client.downloadable import Downloadable from streamrip.client.qobuz import QobuzClient from streamrip.config import Config -from streamrip.media.track import PendingSingle, Track +from streamrip.exceptions import NonStreamableError +from streamrip.media.track import PendingSingle, Track, album_folder from streamrip.metadata import ( AlbumInfo, AlbumMetadata, @@ -40,23 +43,35 @@ def test_pending_resolve(qobuz_client: QobuzClient): shutil.rmtree(dir) +FIXTURES = { + "flac": "tests/silence.flac", + "m4a": "tests/silence.m4a", + "alac": "tests/silence_alac.m4a", +} + + class FakeDownloadable(Downloadable): - """A Downloadable that just writes some bytes, for exercising Track's - file-placement logic without hitting the network. - """ + """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): - data = b"fake audio bytes" - with open(path, "wb") as f: - f.write(data) - callback(len(data)) + shutil.copy(FIXTURES[self.extension], path) + callback(os.path.getsize(path)) async def size(self): - return 17 + 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: @@ -75,7 +90,7 @@ def _make_track(folder: str, extension: str) -> Track: 1, ) meta = TrackMetadata( - info=TrackInfo(id="123", quality=2), + info=TrackInfo(id="123"), title="Song", album=album, artist="Test Artist", @@ -94,35 +109,127 @@ def _make_track(folder: str, extension: str) -> Track: def test_lossy_download_skipped_when_lossless_copy_exists(tmp_path): - (tmp_path / "Song.flac").write_bytes(b"pretend flac bytes") + _place_copy(tmp_path / "Song.flac", "flac") - t = _make_track(str(tmp_path), "m4a") - arun(t.preprocess()) - assert t._skip_lossy_duplicate is True + arun(_make_track(str(tmp_path), "m4a").rip()) - arun(t.download()) 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): - t = _make_track(str(tmp_path), "m4a") - arun(t.preprocess()) - assert t._skip_lossy_duplicate is False + arun(_make_track(str(tmp_path), "m4a").rip()) - arun(t.download()) assert (tmp_path / "Song.m4a").exists() -def test_stale_lossy_sibling_removed_once_lossless_copy_lands(tmp_path): - (tmp_path / "Song.m4a").write_bytes(b"stale aac copy") +def test_lossy_copy_removed_once_lossless_copy_lands(tmp_path): + _place_copy(tmp_path / "Song.m4a", "m4a") - t = _make_track(str(tmp_path), "flac") - arun(t.preprocess()) - assert t._skip_lossy_duplicate is False + arun(_make_track(str(tmp_path), "flac").rip()) - arun(t.download()) assert (tmp_path / "Song.flac").exists() - assert (tmp_path / "Song.m4a").exists() # not cleaned up until postprocess + 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).""" - t._remove_lossy_duplicates() + 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" + ) From 6beeff4d084a5a078d626888542eb7b31e8229b5 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:00:15 +0200 Subject: [PATCH 59/62] chore: release 2.4.0 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- streamrip/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d080c1e9..165099ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes in this fork of [nathom/streamrip](https://github.com/nathom/streamrip). Numbers refer to upstream issues and pull requests. -## 2.3.0 +## 2.4.0 ### Installation and dependencies diff --git a/pyproject.toml b/pyproject.toml index e78e777e..aee8bd7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "streamrip" -version = "2.3.0" +version = "2.4.0" description = "A fast, all-in-one music downloader for Qobuz, Deezer, Tidal, and SoundCloud" authors = [{ name = "nathom", email = "nathanthomas707@gmail.com" }] maintainers = [{ name = "Stensel8" }] diff --git a/streamrip/__init__.py b/streamrip/__init__.py index daf6befa..6167a8bd 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -7,4 +7,4 @@ http.client._MAXHEADERS = 1000 # type: ignore[attr-defined] __all__ = ["Config", "converter", "db", "exceptions", "media", "metadata"] -__version__ = "2.3.0" +__version__ = "2.4.0" From cd6e2973e3c6e5d393ae38de68326c44f939b31e Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:10:21 +0200 Subject: [PATCH 60/62] chore: bump user agent --- streamrip/client/client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/streamrip/client/client.py b/streamrip/client/client.py index 55dd7880..ff12222c 100644 --- a/streamrip/client/client.py +++ b/streamrip/client/client.py @@ -12,8 +12,12 @@ 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. From 9f883eacfa13ad05640807df2b1bdd6bd6ac9a6a Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:26:54 +0200 Subject: [PATCH 61/62] feat: release 2.4.1, prefer_explicit on by default (#12) --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- streamrip/__init__.py | 2 +- streamrip/config.py | 2 +- streamrip/config.toml | 6 ++++-- tests/test_config.py | 8 ++++---- tests/test_config.toml | 2 +- 7 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 165099ef..137ac4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes in this fork of [nathom/streamrip](https://github.com/nathom/streamrip). Numbers refer to upstream issues and pull requests. +## 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 diff --git a/pyproject.toml b/pyproject.toml index aee8bd7f..5e2487e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "streamrip" -version = "2.4.0" +version = "2.4.1" description = "A fast, all-in-one music downloader for Qobuz, Deezer, Tidal, and SoundCloud" authors = [{ name = "nathom", email = "nathanthomas707@gmail.com" }] maintainers = [{ name = "Stensel8" }] diff --git a/streamrip/__init__.py b/streamrip/__init__.py index 6167a8bd..d5849959 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -7,4 +7,4 @@ http.client._MAXHEADERS = 1000 # type: ignore[attr-defined] __all__ = ["Config", "converter", "db", "exceptions", "media", "metadata"] -__version__ = "2.4.0" +__version__ = "2.4.1" diff --git a/streamrip/config.py b/streamrip/config.py index 3f1f6bff..08e061dc 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -114,7 +114,7 @@ class MetadataConfig: set_playlist_to_album: bool renumber_playlist_tracks: bool exclude: list[str] - prefer_explicit: bool = False + prefer_explicit: bool = True @dataclass(slots=True) diff --git a/streamrip/config.toml b/streamrip/config.toml index 0e1fccd9..adb1b7db 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -164,8 +164,10 @@ renumber_playlist_tracks = true # 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 = false +# 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] diff --git a/tests/test_config.py b/tests/test_config.py index e9c35203..6034b149 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -275,8 +275,8 @@ def test_config_dont_update_without_set_modified(): assert conf2.session.downloads.folder == "test_folder" -def test_prefer_explicit_defaults_false(): - assert Config.defaults().session.metadata.prefer_explicit is False +def test_prefer_explicit_defaults_true(): + assert Config.defaults().session.metadata.prefer_explicit is True def test_prefer_explicit_missing_from_toml_still_loads(): @@ -284,10 +284,10 @@ def test_prefer_explicit_missing_from_toml_still_loads(): # 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 = false\n", "\n") + 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 False + assert data.metadata.prefer_explicit is True # Other tests for the Config class can be added as needed diff --git a/tests/test_config.toml b/tests/test_config.toml index e60523be..e6188a8e 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -141,7 +141,7 @@ renumber_playlist_tracks = true 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 = false +prefer_explicit = true # Changes the folder and file names generated by streamrip. [filepaths] From 0fd83a0855cf987c323ce4868dbef403c311bd24 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:15:18 +0200 Subject: [PATCH 62/62] feat: release 2.4.2, sequential progress display and Tidal deduping Albums/playlists download one at a time with a resolve-phase spinner and per-track/album quality labels, and a Console-sharing bug that corrupted long-running terminal output is fixed. Tidal albums listed more than once under an artist (clean/explicit, quality tiers, bracket-style edition names) now download only their best copy, and requests held back by a rate limit no longer all retry in the same instant and re-trip it. --- CHANGELOG.md | 13 +++++ pyproject.toml | 2 +- streamrip/__init__.py | 2 +- streamrip/client/tidal.py | 43 ++++++++++++++- streamrip/config.toml | 2 +- streamrip/console.py | 8 ++- streamrip/media/album.py | 37 +++++++++---- streamrip/media/artist.py | 9 ++- streamrip/media/playlist.py | 19 ++++--- streamrip/media/track.py | 14 +++-- streamrip/metadata/util.py | 12 ++++ streamrip/progress.py | 85 +++++++++++++++++++++++------ streamrip/rip/cli.py | 2 +- tests/test_album.py | 14 ++++- tests/test_artist.py | 38 +++++-------- tests/test_error_handling.py | 3 + tests/test_tidal.py | 103 +++++++++++++++++++++++++++++++++++ tests/test_tidal_requests.py | 9 ++- 18 files changed, 338 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 137ac4a2..ec58234d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 5e2487e6..83c343a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "streamrip" -version = "2.4.1" +version = "2.4.2" description = "A fast, all-in-one music downloader for Qobuz, Deezer, Tidal, and SoundCloud" authors = [{ name = "nathom", email = "nathanthomas707@gmail.com" }] maintainers = [{ name = "Stensel8" }] diff --git a/streamrip/__init__.py b/streamrip/__init__.py index d5849959..97536c5f 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -7,4 +7,4 @@ http.client._MAXHEADERS = 1000 # type: ignore[attr-defined] __all__ = ["Config", "converter", "db", "exceptions", "media", "metadata"] -__version__ = "2.4.1" +__version__ = "2.4.2" diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index a903eeab..ad7e4a35 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -15,6 +15,7 @@ MissingCredentialsError, NonStreamableError, ) +from ..metadata.util import tidal_quality_id from .client import Client, new_session from .downloadable import TidalDASHDownloadable, TidalDownloadable @@ -85,6 +86,35 @@ def _retry_after(resp, attempt: int) -> float: 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.""" @@ -240,6 +270,8 @@ async def get_metadata(self, item_id: str, media_type: str) -> dict: item["albums"] = album_resp["items"] item["albums"].extend(ep_resp["items"]) + 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( @@ -381,6 +413,13 @@ async def _try_hires(self, track_id: str): 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" @@ -640,7 +679,9 @@ async def _api_request(self, path: str, params=None, base: str = BASE) -> dict: while True: attempt += 1 if (pause := self._root._retry_at - time.monotonic()) > 0: - await asyncio.sleep(pause) + # + 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: diff --git a/streamrip/config.toml b/streamrip/config.toml index adb1b7db..0f8ab7ea 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -14,7 +14,7 @@ max_connections = 6 # 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 = 40 +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 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/media/album.py b/streamrip/media/album.py index 6aae08be..265019b6 100644 --- a/streamrip/media/album.py +++ b/streamrip/media/album.py @@ -5,19 +5,21 @@ from .. import progress 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_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, 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. -RESOLVE_CONCURRENCY = 4 +# 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): @@ -51,8 +53,18 @@ class Album(Media): db: Database client: Client | None = None + 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 preprocess(self): - progress.add_title(self.meta.album) + 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. @@ -67,14 +79,19 @@ async def preprocess(self): ) async def download(self): - await rip_tracks( - self.tracks, - RESOLVE_CONCURRENCY, - self.config.session.metadata.prefer_explicit, - ) + 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) diff --git a/streamrip/media/artist.py b/streamrip/media/artist.py index 1ef21d1e..93bc633b 100644 --- a/streamrip/media/artist.py +++ b/streamrip/media/artist.py @@ -15,11 +15,10 @@ logger = logging.getLogger("streamrip") -# Resolve only N albums at a time to avoid initial latency of resolving ALL -# albums and tracks before any downloads, and to avoid bursting past the -# streaming service's rate limit: resolving + starting downloads for many -# albums at once fires many API calls almost simultaneously. -RESOLVE_CHUNK_SIZE = 4 +# 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( diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 11bf3cd4..30f7f796 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -88,17 +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): - await rip_tracks( - self.tracks, - RESOLVE_CONCURRENCY, - self.config.session.metadata.prefer_explicit, - ) + 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) diff --git a/streamrip/media/track.py b/streamrip/media/track.py index ce9cf771..fe1a07a7 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -15,6 +15,7 @@ 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 @@ -79,18 +80,23 @@ async def preprocess(self): ) 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): 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): 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}" + label = f"Track {self.meta.tracknumber} {quality}" if attempt > 1: label += f" (retry {attempt - 1})" try: @@ -127,7 +133,7 @@ async def download(self): # skips it, which would leave a phantom title in the # progress display for the rest of the run. if self.is_single: - remove_title(self.meta.title) + remove_title(id(self), self.config.session.cli.progress_bars) raise TrackDownloadFailedError( f"{self.meta.title} ({self.meta.info.id})" ) from e @@ -138,7 +144,7 @@ async def postprocess(self): return if self.is_single: - remove_title(self.meta.title) + remove_title(id(self), self.config.session.cli.progress_bars) exclude = self.config.session.metadata.exclude await tag_file(self.download_path, self.meta, self.cover_path, exclude) diff --git a/streamrip/metadata/util.py b/streamrip/metadata/util.py index 4a7b120f..712658ca 100644 --- a/streamrip/metadata/util.py +++ b/streamrip/metadata/util.py @@ -54,6 +54,18 @@ def tidal_quality_id(audio_quality: str | None) -> int: 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], diff --git a/streamrip/progress.py b/streamrip/progress.py index 5bc6fc5f..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,7 +20,14 @@ class ProgressManager: def __init__(self): self.started = False + # 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}%", @@ -30,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: @@ -97,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 380e4b85..5d102c49 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -136,7 +136,7 @@ def rip( level="INFO", format="%(message)s", datefmt="[%X]", - handlers=[RichHandler()], + handlers=[RichHandler(console=console)], ) logger = logging.getLogger("streamrip") if verbose: diff --git a/tests/test_album.py b/tests/test_album.py index b50d519f..a274cc2b 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -10,11 +10,20 @@ 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=MagicMock(), tracks=tracks, config=config, folder="/x", db=MagicMock() + meta=_meta(), tracks=tracks, config=config, folder="/x", db=MagicMock() ) @@ -63,8 +72,7 @@ async def resolve(self): def _pending_album(monkeypatch, tmp_path, tracklist, downloaded): - meta = MagicMock() - meta.album = "Encore" + meta = _meta("Encore") client = MagicMock() client.source = "tidal" client.get_metadata = AsyncMock(return_value={}) diff --git a/tests/test_artist.py b/tests/test_artist.py index 575ab599..60c26d1b 100644 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -43,40 +43,32 @@ async def resolve(self): @pytest.mark.asyncio -async def test_next_album_starts_as_soon_as_a_slot_frees_up(): - """One slow album must not hold back the albums queued behind it.""" - fast = RESOLVE_CHUNK_SIZE * 3 - finished = 0 - release = asyncio.Event() +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: - def __init__(self, slow): - self.slow = slow - async def rip(self): - nonlocal finished - if self.slow: - # Only finishes once every fast album has, which can't happen - # if the ones behind it wait for it in batches. - await release.wait() - return - finished += 1 - if finished == fast: - release.set() + nonlocal concurrent, max_concurrent + concurrent += 1 + max_concurrent = max(max_concurrent, concurrent) + await asyncio.sleep(0) + concurrent -= 1 class Pending: - def __init__(self, slow): - self.slow = slow - async def resolve(self): - return Album(self.slow) + return Album() - albums = [Pending(True)] + [Pending(False) for _ in range(fast)] + 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 finished == fast + assert max_concurrent == 1 @pytest.mark.asyncio diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 8468cc3e..8e98abdb 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -48,6 +48,9 @@ async def test_album_handles_failed_track(self, prefer_explicit): 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() diff --git a/tests/test_tidal.py b/tests/test_tidal.py index 9eed49c7..701da086 100644 --- a/tests/test_tidal.py +++ b/tests/test_tidal.py @@ -365,6 +365,109 @@ async def test_single_search_hit_is_returned(): 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 diff --git a/tests/test_tidal_requests.py b/tests/test_tidal_requests.py index 31ca3dfa..6b890f61 100644 --- a/tests/test_tidal_requests.py +++ b/tests/test_tidal_requests.py @@ -120,21 +120,24 @@ async def test_rate_limit_waits_for_retry_after_and_pauses_later_requests(sleeps # 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 - assert sleeps == [pytest.approx(7, abs=0.5), pytest.approx(7, abs=0.5)] + # +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 sleeps == [pytest.approx(RATE_LIMIT_PAUSE, abs=0.5)] + 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 sleeps == [pytest.approx(MAX_RETRY_DELAY, abs=0.5)] + assert len(sleeps) == 1 + assert MAX_RETRY_DELAY <= sleeps[0] < MAX_RETRY_DELAY + 2 @pytest.mark.asyncio