diff --git a/README.md b/README.md index 0df40b69..fcbd5390 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,40 @@ For more customization, see the config file rip config open ``` +### Qobuz login (token-based) + +Qobuz no longer supports the old direct email/password API login flow used by streamrip. +Streamrip now attempts to capture `user.id` and `user_auth_token` automatically in a managed browser session. + +Before using automatic capture, install Playwright browser runtime: + +```bash +pip install playwright +playwright install chromium +``` + +If automatic capture fails or times out, streamrip falls back to manual token input. + +In your config: + +```toml +[qobuz] +use_auth_token = true +email_or_userid = "YOUR_QOBUZ_USER_ID" +password_or_token = "YOUR_USER_AUTH_TOKEN" +``` + +To refresh an expired token: + +1. Run any Qobuz command again (streamrip will retry auto-capture) +2. If needed, log in at `qobuz.com` or `play.qobuz.com` +3. Open DevTools -> Network +4. Filter requests by `user/login` +5. Open a successful request response and copy: + - `user_auth_token` + - `user.id` +6. Update `email_or_userid` and `password_or_token` in config + 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 ``` diff --git a/poetry.lock b/poetry.lock index e1814bd8..b5709e45 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "aiodns" @@ -496,11 +496,11 @@ 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\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "deezer-py" @@ -523,7 +523,7 @@ version = "1.2.2" 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"}, @@ -652,6 +652,73 @@ files = [ {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, ] +[[package]] +name = "greenlet" +version = "3.3.2" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f"}, + {file = "greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef"}, + {file = "greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca"}, + {file = "greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f"}, + {file = "greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358"}, + {file = "greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99"}, + {file = "greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be"}, + {file = "greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5"}, + {file = "greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd"}, + {file = "greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070"}, + {file = "greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79"}, + {file = "greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395"}, + {file = "greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f"}, + {file = "greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643"}, + {file = "greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab"}, + {file = "greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a"}, + {file = "greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b"}, + {file = "greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124"}, + {file = "greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327"}, + {file = "greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506"}, + {file = "greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce"}, + {file = "greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5"}, + {file = "greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492"}, + {file = "greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71"}, + {file = "greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4"}, + {file = "greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727"}, + {file = "greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e"}, + {file = "greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a"}, + {file = "greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + [[package]] name = "idna" version = "3.10" @@ -673,7 +740,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -905,7 +972,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -1069,13 +1136,35 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-a 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]] +name = "playwright" +version = "1.58.0" +description = "A high-level API to automate web browsers" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606"}, + {file = "playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71"}, + {file = "playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117"}, + {file = "playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b"}, + {file = "playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa"}, + {file = "playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99"}, + {file = "playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8"}, + {file = "playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b"}, +] + +[package.dependencies] +greenlet = ">=3.1.1,<4.0.0" +pyee = ">=13,<14" + [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +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"}, @@ -1346,6 +1435,24 @@ files = [ {file = "pycryptodomex-3.21.0.tar.gz", hash = "sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c"}, ] +[[package]] +name = "pyee" +version = "13.0.1" +description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"}, + {file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"}, +] + +[package.dependencies] +typing-extensions = "*" + +[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 = "pyflakes" version = "2.3.1" @@ -1379,7 +1486,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +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"}, @@ -1402,7 +1509,7 @@ version = "0.21.2" description = "Pytest support for asyncio" optional = false python-versions = ">=3.7" -groups = ["main"] +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"}, @@ -1421,7 +1528,7 @@ version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" -groups = ["main"] +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"}, @@ -1538,7 +1645,7 @@ version = "2.2.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"}, @@ -1618,11 +1725,11 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" 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"}, ] +markers = {dev = "python_version == \"3.10\""} [[package]] name = "urllib3" @@ -1772,4 +1879,4 @@ ssl = ["certifi"] [metadata] lock-version = "2.1" python-versions = ">=3.10 <4.0" -content-hash = "66008fa225aea220c062e58b547d56492a867505c0fd225637c0d57af9e784ff" +content-hash = "3b15bf64db7402750dab4e3d7f6302e796c2c9f0dbe72385ccf0509b2ee01c37" diff --git a/pyproject.toml b/pyproject.toml index c18eb67f..ef68bfdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ aiolimiter = "^1.1.0" rich = "^13.6.0" click-help-colors = "^0.9.2" certifi = { version = "^2025.1.31", optional = true } +playwright = "^1.58.0" [tool.poetry.urls] "Bug Reports" = "https://github.com/nathom/streamrip/issues" diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 734e2b82..c8cbb5f3 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -193,14 +193,21 @@ async def login(self): "app_id": str(c.app_id), } - logger.debug("Request params %s", params) + logger.debug("Request params %s", self._redact_auth_payload(params)) status, resp = await self._api_request("user/login", params) - logger.debug("Login resp: %s", resp) + logger.debug("Login resp: %s", self._redact_auth_payload(resp)) if status == 401: - raise AuthenticationError(f"Invalid credentials from params {params}") + if c.use_auth_token: + raise AuthenticationError( + "Invalid Qobuz token or user id. The token may have expired; " + "refresh user_auth_token from a logged-in browser session." + ) + raise AuthenticationError("Invalid Qobuz credentials.") elif status == 400: - raise InvalidAppIdError(f"Invalid app id from params {params}") + raise InvalidAppIdError( + f"Invalid app id from params {self._redact_auth_payload(params)}" + ) logger.debug("Logged in to Qobuz") @@ -453,3 +460,11 @@ async def _api_request(self, epoint: str, params: dict) -> tuple[int, dict]: def get_quality(quality: int): quality_map = (5, 6, 7, 27) return quality_map[quality - 1] + + @staticmethod + def _redact_auth_payload(payload: dict) -> dict: + redacted = dict(payload) + for key in ("password", "user_auth_token"): + if key in redacted and redacted[key]: + redacted[key] = "***REDACTED***" + return redacted diff --git a/streamrip/config.toml b/streamrip/config.toml index 029115bd..dcbf180e 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -27,11 +27,11 @@ quality = 3 # This will download booklet pdfs that are included with some albums download_booklets = true -# Authenticate to Qobuz using auth token? Value can be true/false only -use_auth_token = false -# Enter your userid if the above use_auth_token is set to true, else enter your email +# Qobuz web login now uses OAuth/reCAPTCHA. Use token auth from a logged-in browser session. +use_auth_token = true +# Enter your Qobuz user id (numeric string) email_or_userid = "" -# Enter your auth token if the above use_auth_token is set to true, else enter the md5 hash of your plaintext password +# Enter your Qobuz user_auth_token (JWT). Refresh it when it expires. password_or_token = "" # Do not change app_id = "" diff --git a/streamrip/rip/main.py b/streamrip/rip/main.py index 562773fc..5e0eb0df 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 AuthenticationError from ..media import ( Media, Pending, @@ -147,7 +148,17 @@ async def get_logged_in_client(self, source: str): else: with console.status(f"[cyan]Logging into {source}", spinner="dots"): # Log into client using credentials from config - await client.login() + try: + await client.login() + except AuthenticationError: + if source != "qobuz": + raise + console.print( + "[yellow]Saved Qobuz token appears invalid or expired. " + "Please provide a refreshed token.[/yellow]" + ) + await prompter.prompt_and_login() + prompter.save() assert client.logged_in return client diff --git a/streamrip/rip/prompter.py b/streamrip/rip/prompter.py index 0cc30b83..51254e81 100644 --- a/streamrip/rip/prompter.py +++ b/streamrip/rip/prompter.py @@ -1,16 +1,16 @@ import asyncio -import hashlib import logging import time 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 logger = logging.getLogger("streamrip") @@ -52,35 +52,62 @@ 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() + console.print( + "[yellow]Invalid Qobuz token or user id. " + "The token may have expired, please refresh it from your browser.[/yellow]" + ) + 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() - 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) + async def _prompt_creds_and_set_session_config(self): + console.print( + "[cyan]Attempting automatic Qobuz token capture in a managed browser...[/cyan]" + ) + try: + user_id, token = await capture_qobuz_auth_token(timeout_s=300) + self._set_session_qobuz_auth_token(user_id, token) + console.print( + f"[green]Credentials saved to config file at [bold cyan]{self.config.path}", + ) + return + except QobuzTokenCaptureError as exc: + console.print(f"[yellow]{exc}[/yellow]") - pwd = hashlib.md5(pwd_input.encode("utf-8")).hexdigest() + console.print( + "[cyan]Qobuz now requires token-based login.[/cyan]\n" + "1) Log in at qobuz.com\n" + "2) Open browser DevTools -> Network\n" + "3) Find a successful [bold]user/login[/bold] request\n" + "4) Copy [bold]user_auth_token[/bold] and [bold]user id[/bold]" + ) + if Confirm.ask("Open Qobuz login page in your browser now?", default=True): + launch("https://play.qobuz.com/login") + + user_id = Prompt.ask("Enter your Qobuz user id") + token = Prompt.ask("Enter your Qobuz user_auth_token", password=True) + self._set_session_qobuz_auth_token(user_id, token) console.print( f"[green]Credentials saved to config file at [bold cyan]{self.config.path}", ) + + def _set_session_qobuz_auth_token(self, user_id: str, token: str): c = self.config.session.qobuz - c.use_auth_token = False - c.email_or_userid = email - c.password_or_token = pwd + c.use_auth_token = True + c.email_or_userid = user_id + c.password_or_token = token def save(self): c = self.config.session.qobuz cf = self.config.file.qobuz - cf.use_auth_token = False + cf.use_auth_token = True 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..dc84af4a --- /dev/null +++ b/streamrip/rip/qobuz_token_capture.py @@ -0,0 +1,110 @@ +import asyncio +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.""" + + +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 and run `playwright install chromium`, or use manual token input." + ) 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_qobuz_auth_flow.py b/tests/test_qobuz_auth_flow.py new file mode 100644 index 00000000..4559b1a6 --- /dev/null +++ b/tests/test_qobuz_auth_flow.py @@ -0,0 +1,254 @@ +import pytest +from util import arun + +from streamrip.client.qobuz import QobuzClient +from streamrip.config import Config +from streamrip.exceptions import AuthenticationError +from streamrip.rip.main import Main +from streamrip.rip.prompter import QobuzPrompter +from streamrip.rip.qobuz_token_capture import ( + QobuzTokenCaptureError, + capture_qobuz_auth_token, +) + + +class _FakeSession: + def __init__(self): + self.headers = {} + + async def close(self): + return None + + +def test_qobuz_login_uses_user_auth_token_params(monkeypatch): + config = Config.defaults() + c = config.session.qobuz + c.use_auth_token = True + c.email_or_userid = "123456" + c.password_or_token = "jwt-token" + c.app_id = "123456789" + c.secrets = ["secret"] + + client = QobuzClient(config) + captured = {} + + async def fake_get_session(*, verify_ssl): + return _FakeSession() + + async def fake_api_request(epoint, params): + captured["epoint"] = epoint + captured["params"] = params + return ( + 200, + { + "user": {"credential": {"parameters": {"can_stream": True}}}, + "user_auth_token": "returned-token", + }, + ) + + async def fake_get_valid_secret(_): + return "working-secret" + + monkeypatch.setattr(client, "get_session", fake_get_session) + monkeypatch.setattr(client, "_api_request", fake_api_request) + monkeypatch.setattr(client, "_get_valid_secret", fake_get_valid_secret) + + arun(client.login()) + + assert captured["epoint"] == "user/login" + assert captured["params"]["user_id"] == "123456" + assert captured["params"]["user_auth_token"] == "jwt-token" + assert "email" not in captured["params"] + assert "password" not in captured["params"] + assert client.session.headers["X-User-Auth-Token"] == "returned-token" + + +def test_qobuz_login_401_message_is_token_specific_and_redacted(monkeypatch): + config = Config.defaults() + c = config.session.qobuz + c.use_auth_token = True + c.email_or_userid = "123456" + c.password_or_token = "sensitive-token" + c.app_id = "123456789" + c.secrets = ["secret"] + + client = QobuzClient(config) + + async def fake_get_session(*, verify_ssl): + return _FakeSession() + + async def fake_api_request(_epoint, _params): + return 401, {} + + monkeypatch.setattr(client, "get_session", fake_get_session) + monkeypatch.setattr(client, "_api_request", fake_api_request) + + with pytest.raises(AuthenticationError) as exc: + arun(client.login()) + + assert "token may have expired" in str(exc.value) + assert "sensitive-token" not in str(exc.value) + + redacted = QobuzClient._redact_auth_payload( + { + "user_auth_token": "secret-value", + "password": "legacy-password", + "app_id": "123456789", + } + ) + assert redacted["user_auth_token"] == "***REDACTED***" + assert redacted["password"] == "***REDACTED***" + assert redacted["app_id"] == "123456789" + + +def test_main_reprompts_qobuz_on_authentication_error(monkeypatch): + class FakeClient: + source = "qobuz" + logged_in = False + + async def login(self): + raise AuthenticationError("stale token") + + class FakePrompter: + def __init__(self, _config, client): + self.client = client + self.prompted = False + self.saved = False + + def has_creds(self): + return True + + async def prompt_and_login(self): + self.prompted = True + self.client.logged_in = True + + def save(self): + self.saved = True + + config = Config.defaults() + config.session.database.downloads_enabled = False + config.session.database.failed_downloads_enabled = False + main = Main(config) + fake_client = FakeClient() + main.clients["qobuz"] = fake_client + state = {} + + def fake_get_prompter(client, conf): + p = FakePrompter(conf, client) + state["prompter"] = p + return p + + monkeypatch.setattr("streamrip.rip.main.get_prompter", fake_get_prompter) + + result = arun(main.get_logged_in_client("qobuz")) + assert result is fake_client + assert fake_client.logged_in is True + assert state["prompter"].prompted is True + assert state["prompter"].saved is True + + +def test_qobuz_prompter_auto_capture_sets_session_token(monkeypatch): + config = Config.defaults() + client = QobuzClient(config) + prompter = QobuzPrompter(config, client) + + async def fake_capture(timeout_s=300): + return ("987654", "captured-token") + + monkeypatch.setattr("streamrip.rip.prompter.capture_qobuz_auth_token", fake_capture) + + def fail_prompt(*_args, **_kwargs): + raise AssertionError("Manual prompt should not run on auto-capture success") + + monkeypatch.setattr("streamrip.rip.prompter.Prompt.ask", fail_prompt) + monkeypatch.setattr("streamrip.rip.prompter.Confirm.ask", lambda *a, **k: False) + + arun(prompter._prompt_creds_and_set_session_config()) + c = config.session.qobuz + assert c.use_auth_token is True + assert c.email_or_userid == "987654" + assert c.password_or_token == "captured-token" + + +def test_qobuz_prompter_falls_back_to_manual_prompt(monkeypatch): + config = Config.defaults() + client = QobuzClient(config) + prompter = QobuzPrompter(config, client) + + async def raise_capture_error(timeout_s=300): + raise QobuzTokenCaptureError("capture failed") + + monkeypatch.setattr( + "streamrip.rip.prompter.capture_qobuz_auth_token", raise_capture_error + ) + monkeypatch.setattr("streamrip.rip.prompter.Confirm.ask", lambda *a, **k: False) + + answers = iter(["123123", "manual-token"]) + monkeypatch.setattr( + "streamrip.rip.prompter.Prompt.ask", lambda *a, **k: next(answers) + ) + + arun(prompter._prompt_creds_and_set_session_config()) + c = config.session.qobuz + assert c.use_auth_token is True + assert c.email_or_userid == "123123" + assert c.password_or_token == "manual-token" + + +def test_qobuz_prompter_retries_with_auto_capture_after_auth_error(monkeypatch): + config = Config.defaults() + c = config.session.qobuz + c.use_auth_token = True + c.email_or_userid = "old-user" + c.password_or_token = "stale-token" + + client = QobuzClient(config) + prompter = QobuzPrompter(config, client) + attempts = {"count": 0} + + async def fake_login(): + attempts["count"] += 1 + if attempts["count"] == 1: + raise AuthenticationError("expired token") + client.logged_in = True + + monkeypatch.setattr(client, "login", fake_login) + async def fake_capture(timeout_s=300): + return ("new-user", "new-token") + + monkeypatch.setattr("streamrip.rip.prompter.capture_qobuz_auth_token", fake_capture) + + def fail_prompt(*_args, **_kwargs): + raise AssertionError("Manual prompts should not run in auto-capture retry") + + monkeypatch.setattr("streamrip.rip.prompter.Prompt.ask", fail_prompt) + monkeypatch.setattr("streamrip.rip.prompter.Confirm.ask", lambda *a, **k: False) + + arun(prompter.prompt_and_login()) + assert attempts["count"] == 2 + assert config.session.qobuz.email_or_userid == "new-user" + assert config.session.qobuz.password_or_token == "new-token" + + +def test_capture_qobuz_auth_token_windows_uses_threaded_helper(monkeypatch): + monkeypatch.setattr("streamrip.rip.qobuz_token_capture.platform.system", lambda: "Windows") + + captured = {} + + def fake_windows_capture(timeout_s): + captured["timeout_s"] = timeout_s + return ("win-user", "win-token") + + async def fake_to_thread(func, timeout_s): + captured["func"] = func + return func(timeout_s) + + monkeypatch.setattr( + "streamrip.rip.qobuz_token_capture._capture_qobuz_auth_token_windows", + fake_windows_capture, + ) + monkeypatch.setattr("streamrip.rip.qobuz_token_capture.asyncio.to_thread", fake_to_thread) + + result = arun(capture_qobuz_auth_token(timeout_s=42)) + assert result == ("win-user", "win-token") + assert captured["timeout_s"] == 42