diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6ad8aac6..a12c5208 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,11 +2,11 @@ name: Code coverage on: push: - branches: - - '**' - pull_request: branches: - master +# pull_request: +# branches: +# - master jobs: build: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..978318d7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,16 @@ +name: Ruff + +on: + pull_request: + branches: + - master + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: chartboost/ruff-action@v1 + - uses: chartboost/ruff-action@v1 + with: + args: format --check \ No newline at end of file diff --git a/.gitignore b/.gitignore index a9adcb05..c89cb371 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ venv*/ *.egg-info/ build dist +.pdm-python diff --git a/docs/source/conf.py b/docs/source/conf.py index c9dd88f8..cf2ccd45 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,17 +12,18 @@ # import os import sys -sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, '../..') + +sys.path.insert(0, os.path.abspath(".")) +sys.path.insert(0, "../..") from ytmusicapi import __version__ # noqa: E402 -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" # -- Project information ----------------------------------------------------- -project = 'ytmusicapi' -copyright = '2022, sigma67' -author = 'sigma67' +project = "ytmusicapi" +copyright = "2022, sigma67" +author = "sigma67" # The full version, including alpha/beta/rc tags version = __version__ @@ -37,17 +38,17 @@ extensions = ["sphinx.ext.autodoc"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] -html_theme = "sphinx_rtd_theme" \ No newline at end of file +html_theme = "sphinx_rtd_theme" diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 00000000..73114f5c --- /dev/null +++ b/pdm.lock @@ -0,0 +1,560 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default", "dev"] +strategy = ["cross_platform", "inherit_metadata"] +lock_version = "4.4.1" +content_hash = "sha256:e8454691de99746f0fe6ba42b53f33bd3f0b8928021df648441b496e8c9a2f11" + +[[package]] +name = "alabaster" +version = "0.7.13" +requires_python = ">=3.6" +summary = "A configurable sidebar-enabled Sphinx theme" +groups = ["dev"] +files = [ + {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, + {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, +] + +[[package]] +name = "babel" +version = "2.14.0" +requires_python = ">=3.7" +summary = "Internationalization utilities" +groups = ["dev"] +dependencies = [ + "pytz>=2015.7; python_version < \"3.9\"", +] +files = [ + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, +] + +[[package]] +name = "certifi" +version = "2023.11.17" +requires_python = ">=3.6" +summary = "Python package for providing Mozilla's CA Bundle." +groups = ["default", "dev"] +files = [ + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +requires_python = ">=3.7.0" +summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +groups = ["default", "dev"] +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +summary = "Cross-platform colored terminal text." +groups = ["dev"] +marker = "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"}, +] + +[[package]] +name = "coverage" +version = "7.4.0" +requires_python = ">=3.8" +summary = "Code coverage measurement for Python" +groups = ["dev"] +files = [ + {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, + {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, + {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, + {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, + {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, + {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, + {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, + {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, + {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, + {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, + {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, + {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, + {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, + {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, +] + +[[package]] +name = "docutils" +version = "0.19" +requires_python = ">=3.7" +summary = "Docutils -- Python Documentation Utilities" +groups = ["dev"] +files = [ + {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, + {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, +] + +[[package]] +name = "idna" +version = "3.6" +requires_python = ">=3.5" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["default", "dev"] +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +summary = "Getting image size from png/jpeg/jpeg2000/gif file" +groups = ["dev"] +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "7.0.1" +requires_python = ">=3.8" +summary = "Read metadata from Python packages" +groups = ["dev"] +marker = "python_version < \"3.10\"" +dependencies = [ + "zipp>=0.5", +] +files = [ + {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, + {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, +] + +[[package]] +name = "jinja2" +version = "3.1.2" +requires_python = ">=3.7" +summary = "A very fast and expressive template engine." +groups = ["dev"] +dependencies = [ + "MarkupSafe>=2.0", +] +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[[package]] +name = "markupsafe" +version = "2.1.3" +requires_python = ">=3.7" +summary = "Safely add untrusted strings to HTML/XML markup." +groups = ["dev"] +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "packaging" +version = "23.2" +requires_python = ">=3.7" +summary = "Core utilities for Python packages" +groups = ["dev"] +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pygments" +version = "2.17.2" +requires_python = ">=3.7" +summary = "Pygments is a syntax highlighting package written in Python." +groups = ["dev"] +files = [ + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, +] + +[[package]] +name = "pytz" +version = "2023.3.post1" +summary = "World timezone definitions, modern and historical" +groups = ["dev"] +marker = "python_version < \"3.9\"" +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +requires_python = ">=3.7" +summary = "Python HTTP for Humans." +groups = ["default", "dev"] +dependencies = [ + "certifi>=2017.4.17", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[[package]] +name = "ruff" +version = "0.1.9" +requires_python = ">=3.7" +summary = "An extremely fast Python linter and code formatter, written in Rust." +groups = ["dev"] +files = [ + {file = "ruff-0.1.9-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e6a212f436122ac73df851f0cf006e0c6612fe6f9c864ed17ebefce0eff6a5fd"}, + {file = "ruff-0.1.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:28d920e319783d5303333630dae46ecc80b7ba294aeffedf946a02ac0b7cc3db"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:104aa9b5e12cb755d9dce698ab1b97726b83012487af415a4512fedd38b1459e"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e63bf5a4a91971082a4768a0aba9383c12392d0d6f1e2be2248c1f9054a20da"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d0738917c203246f3e275b37006faa3aa96c828b284ebfe3e99a8cb413c8c4b"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69dac82d63a50df2ab0906d97a01549f814b16bc806deeac4f064ff95c47ddf5"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2aec598fb65084e41a9c5d4b95726173768a62055aafb07b4eff976bac72a592"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:744dfe4b35470fa3820d5fe45758aace6269c578f7ddc43d447868cfe5078bcb"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:479ca4250cab30f9218b2e563adc362bd6ae6343df7c7b5a7865300a5156d5a6"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:aa8344310f1ae79af9ccd6e4b32749e93cddc078f9b5ccd0e45bd76a6d2e8bb6"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:837c739729394df98f342319f5136f33c65286b28b6b70a87c28f59354ec939b"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e6837202c2859b9f22e43cb01992373c2dbfeae5c0c91ad691a4a2e725392464"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:331aae2cd4a0554667ac683243b151c74bd60e78fb08c3c2a4ac05ee1e606a39"}, + {file = "ruff-0.1.9-py3-none-win32.whl", hash = "sha256:8151425a60878e66f23ad47da39265fc2fad42aed06fb0a01130e967a7a064f4"}, + {file = "ruff-0.1.9-py3-none-win_amd64.whl", hash = "sha256:c497d769164df522fdaf54c6eba93f397342fe4ca2123a2e014a5b8fc7df81c7"}, + {file = "ruff-0.1.9-py3-none-win_arm64.whl", hash = "sha256:0e17f53bcbb4fff8292dfd84cf72d767b5e146f009cccd40c2fad27641f8a7a9"}, + {file = "ruff-0.1.9.tar.gz", hash = "sha256:b041dee2734719ddbb4518f762c982f2e912e7f28b8ee4fe1dee0b15d1b6e800"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +summary = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +groups = ["dev"] +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sphinx" +version = "6.2.1" +requires_python = ">=3.8" +summary = "Python documentation generator" +groups = ["dev"] +dependencies = [ + "Jinja2>=3.0", + "Pygments>=2.13", + "alabaster<0.8,>=0.7", + "babel>=2.9", + "colorama>=0.4.5; sys_platform == \"win32\"", + "docutils<0.20,>=0.18.1", + "imagesize>=1.3", + "importlib-metadata>=4.8; python_version < \"3.10\"", + "packaging>=21.0", + "requests>=2.25.0", + "snowballstemmer>=2.0", + "sphinxcontrib-applehelp", + "sphinxcontrib-devhelp", + "sphinxcontrib-htmlhelp>=2.0.0", + "sphinxcontrib-jsmath", + "sphinxcontrib-qthelp", + "sphinxcontrib-serializinghtml>=1.1.5", +] +files = [ + {file = "Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b"}, + {file = "sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912"}, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +requires_python = ">=3.6" +summary = "Read the Docs theme for Sphinx" +groups = ["dev"] +dependencies = [ + "docutils<0.21", + "sphinx<8,>=5", + "sphinxcontrib-jquery<5,>=4", +] +files = [ + {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, + {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.4" +requires_python = ">=3.8" +summary = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +groups = ["dev"] +files = [ + {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, + {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +requires_python = ">=3.5" +summary = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +groups = ["dev"] +files = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.1" +requires_python = ">=3.8" +summary = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +groups = ["dev"] +files = [ + {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, + {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +requires_python = ">=2.7" +summary = "Extension to include jQuery on newer Sphinx releases" +groups = ["dev"] +dependencies = [ + "Sphinx>=1.8", +] +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +requires_python = ">=3.5" +summary = "A sphinx extension which renders display math in HTML via JavaScript" +groups = ["dev"] +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +requires_python = ">=3.5" +summary = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +groups = ["dev"] +files = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +requires_python = ">=3.5" +summary = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +groups = ["dev"] +files = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] + +[[package]] +name = "urllib3" +version = "2.1.0" +requires_python = ">=3.8" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +groups = ["default", "dev"] +files = [ + {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, + {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, +] + +[[package]] +name = "zipp" +version = "3.17.0" +requires_python = ">=3.8" +summary = "Backport of pathlib-compatible object wrapper for zip files" +groups = ["dev"] +marker = "python_version < \"3.10\"" +files = [ + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, +] diff --git a/pyproject.toml b/pyproject.toml index cb28854e..314a7032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,6 @@ dependencies = [ ] dynamic = ["version", "readme"] -[project.optional-dependencies] -dev = ['pre-commit', 'flake8', 'yapf', 'coverage', 'sphinx', 'sphinx-rtd-theme'] - [project.scripts] ytmusicapi = "ytmusicapi.setup:main" @@ -28,8 +25,6 @@ repository = "https://github.com/sigma67/ytmusicapi" requires = ["setuptools>=65", "setuptools_scm[toml]>=7"] build-backend = "setuptools.build_meta" -[tool.setuptools_scm] - [tool.setuptools.dynamic] readme = {file = ["README.rst"]} @@ -39,9 +34,20 @@ include-package-data=false [tool.setuptools.package-data] "*" = ["**.rst", "**.py", "**.mo"] -[tool.yapf] -column_limit = 99 -split_before_arithmetic_operator = true - [tool.coverage.run] command_line = "-m unittest discover tests" + +[tool.ruff] +line-length = 110 +ignore = [ "F403", "F405", "F821", "E731" ] +extend-select = [ + "I", # isort +] + +[tool.pdm.dev-dependencies] +dev = [ + "coverage>=7.4.0", + 'sphinx<7', + 'sphinx-rtd-theme', + "ruff>=0.1.9", +] \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index d43e1000..0e1825f0 100644 --- a/tests/test.py +++ b/tests/test.py @@ -9,9 +9,9 @@ from requests import Response from ytmusicapi.auth.types import AuthType -from ytmusicapi.setup import main, setup # noqa: E402 -from ytmusicapi.ytmusic import YTMusic, OAuthCredentials # noqa: E402 from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET +from ytmusicapi.setup import main, setup # noqa: E402 +from ytmusicapi.ytmusic import OAuthCredentials, YTMusic # noqa: E402 def get_resource(file: str) -> str: @@ -30,7 +30,7 @@ def get_resource(file: str) -> str: "user_code": "", "expires_in": 1800, "interval": 5, - "verification_url": "https://www.google.com/device" + "verification_url": "https://www.google.com/device", } oauth_filepath = get_resource(config["auth"]["oauth_file"]) @@ -40,7 +40,6 @@ def get_resource(file: str) -> str: class TestYTMusic(unittest.TestCase): - @classmethod def setUpClass(cls): warnings.filterwarnings(action="ignore", message="unclosed", category=ResourceWarning) @@ -51,8 +50,7 @@ def setUpClass(cls): cls.yt_alt_oauth = YTMusic(browser_filepath, oauth_credentials=alt_oauth_creds) cls.yt_auth = YTMusic(browser_filepath, location="GB") cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) - cls.yt_empty = YTMusic(config["auth"]["headers_empty"], - config["auth"]["brand_account_empty"]) + cls.yt_empty = YTMusic(config["auth"]["headers_empty"], config["auth"]["brand_account_empty"]) @mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", browser_filepath]) def test_setup_browser(self): @@ -88,7 +86,7 @@ def test_oauth_tokens(self): self.assertIsNotNone(self.yt_oauth._token) # set reference file - with open(oauth_filepath, 'r') as f: + with open(oauth_filepath, "r") as f: first_json = json.load(f) # pull reference values from underlying token @@ -110,7 +108,7 @@ def test_oauth_tokens(self): # check token is propagating properly self.assertEqual(second_token, second_token_inner) - with open(oauth_filepath, 'r') as f2: + with open(oauth_filepath, "r") as f2: second_json = json.load(f2) # ensure token is updating local file @@ -119,7 +117,7 @@ def test_oauth_tokens(self): def test_oauth_custom_client(self): # ensure client works/ignores alt if browser credentials passed as auth self.assertNotEqual(self.yt_alt_oauth.auth_type, AuthType.OAUTH_CUSTOM_CLIENT) - with open(oauth_filepath, 'r') as f: + with open(oauth_filepath, "r") as f: token_dict = json.load(f) # oauth token dict entry and alt self.yt_alt_oauth = YTMusic(token_dict, oauth_credentials=alt_oauth_creds) @@ -154,87 +152,74 @@ def test_search_filters(self): query = "hip hop playlist" results = self.yt_auth.search(query, filter="songs") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'song' for item in results)) + self.assertTrue(all(item["resultType"] == "song" for item in results)) results = self.yt_auth.search(query, filter="videos") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'video' for item in results)) + self.assertTrue(all(item["resultType"] == "video" for item in results)) results = self.yt_auth.search(query, filter="albums", limit=40) self.assertGreater(len(results), 20) - self.assertTrue(all(item['resultType'] == 'album' for item in results)) + self.assertTrue(all(item["resultType"] == "album" for item in results)) results = self.yt_auth.search("project-2", filter="artists", ignore_spelling=True) self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'artist' for item in results)) + self.assertTrue(all(item["resultType"] == "artist" for item in results)) results = self.yt_auth.search("classical music", filter="playlists") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("clasical music", filter="playlists", ignore_spelling=True) self.assertGreater(len(results), 10) - results = self.yt_auth.search("clasic rock", - filter="community_playlists", - ignore_spelling=True) + results = self.yt_auth.search("clasic rock", filter="community_playlists", ignore_spelling=True) self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("hip hop", filter="featured_playlists") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("some user", filter="profiles") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'profile' for item in results)) + self.assertTrue(all(item["resultType"] == "profile" for item in results)) results = self.yt_auth.search(query, filter="podcasts") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'podcast' for item in results)) + self.assertTrue(all(item["resultType"] == "podcast" for item in results)) results = self.yt_auth.search(query, filter="episodes") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'episode' for item in results)) + self.assertTrue(all(item["resultType"] == "episode" for item in results)) def test_search_uploads(self): self.assertRaises( Exception, self.yt.search, - config['queries']['uploads_songs'], + config["queries"]["uploads_songs"], filter="songs", scope="uploads", limit=40, ) - results = self.yt_auth.search(config['queries']['uploads_songs'], - scope="uploads", - limit=40) + results = self.yt_auth.search(config["queries"]["uploads_songs"], scope="uploads", limit=40) self.assertGreater(len(results), 20) def test_search_library(self): - results = self.yt_oauth.search(config['queries']['library_any'], scope="library") + results = self.yt_oauth.search(config["queries"]["library_any"], scope="library") self.assertGreater(len(results), 5) - results = self.yt_alt_oauth.search(config['queries']['library_songs'], - filter="songs", - scope="library", - limit=40) + results = self.yt_alt_oauth.search( + config["queries"]["library_songs"], filter="songs", scope="library", limit=40 + ) self.assertGreater(len(results), 10) - results = self.yt_auth.search(config['queries']['library_albums'], - filter="albums", - scope="library", - limit=40) + results = self.yt_auth.search( + config["queries"]["library_albums"], filter="albums", scope="library", limit=40 + ) self.assertGreaterEqual(len(results), 4) - results = self.yt_auth.search(config['queries']['library_artists'], - filter="artists", - scope="library", - limit=40) + results = self.yt_auth.search( + config["queries"]["library_artists"], filter="artists", scope="library", limit=40 + ) self.assertGreaterEqual(len(results), 1) - results = self.yt_auth.search(config['queries']['library_playlists'], - filter="playlists", - scope="library") + results = self.yt_auth.search( + config["queries"]["library_playlists"], filter="playlists", scope="library" + ) self.assertGreaterEqual(len(results), 1) - self.assertRaises(Exception, - self.yt_auth.search, - "beatles", - filter="community_playlists", - scope="library", - limit=40) - self.assertRaises(Exception, - self.yt_auth.search, - "beatles", - filter="featured_playlists", - scope="library", - limit=40) + self.assertRaises( + Exception, self.yt_auth.search, "beatles", filter="community_playlists", scope="library", limit=40 + ) + self.assertRaises( + Exception, self.yt_auth.search, "beatles", filter="featured_playlists", scope="library", limit=40 + ) def test_get_artist(self): results = self.yt.get_artist("MPLAUCmMUZbaYdNH0bEd1PAlAqsA") @@ -243,10 +228,7 @@ def test_get_artist(self): # test correctness of related artists related = results["related"]["results"] self.assertEqual( - len([ - x for x in related - if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"} - ]), + len([x for x in related if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"}]), len(related), ) @@ -255,14 +237,12 @@ def test_get_artist(self): def test_get_artist_albums(self): artist = self.yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") - results = self.yt.get_artist_albums(artist["albums"]["browseId"], - artist["albums"]["params"]) + results = self.yt.get_artist_albums(artist["albums"]["browseId"], artist["albums"]["params"]) self.assertGreater(len(results), 0) def test_get_artist_singles(self): artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - results = self.yt.get_artist_albums(artist["singles"]["browseId"], - artist["singles"]["params"]) + results = self.yt.get_artist_albums(artist["singles"]["browseId"], artist["singles"]["params"]) self.assertGreater(len(results), 0) def test_get_user(self): @@ -271,8 +251,7 @@ def test_get_user(self): def test_get_user_playlists(self): results = self.yt.get_user("UCPVhZsC2od1xjGhgEc2NEPQ") - results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", - results["playlists"]["params"]) + results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", results["playlists"]["params"]) self.assertGreater(len(results), 100) def test_get_album_browse_id(self): @@ -280,9 +259,8 @@ def test_get_album_browse_id(self): browse_id = self.yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") self.assertEqual(browse_id, sample_album) with self.subTest(): - escaped_browse_id = self.yt.get_album_browse_id( - "OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") - self.assertEqual(escaped_browse_id, 'MPREb_scJdtUCpPE2') + escaped_browse_id = self.yt.get_album_browse_id("OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") + self.assertEqual(escaped_browse_id, "MPREb_scJdtUCpPE2") def test_get_album(self): results = self.yt_auth.get_album(sample_album) @@ -380,12 +358,10 @@ def test_get_watch_playlist(self): self.assertGreater(len(playlist["tracks"]), 45) playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track self.assertGreaterEqual(len(playlist["tracks"]), 25) - playlist = self.yt.get_watch_playlist(playlistId=config['albums']['album_browse_id'], - shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint('albums', 'album_track_length')) - playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], - shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint('playlists', 'own_length')) + playlist = self.yt.get_watch_playlist(playlistId=config["albums"]["album_browse_id"], shuffle=True) + self.assertEqual(len(playlist["tracks"]), config.getint("albums", "album_track_length")) + playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) + self.assertEqual(len(playlist["tracks"]), config.getint("playlists", "own_length")) ################ # LIBRARY @@ -473,13 +449,11 @@ def test_rate_song(self): def test_edit_song_library_status(self): album = self.yt_brand.get_album(sample_album) - response = self.yt_brand.edit_song_library_status( - album["tracks"][0]["feedbackTokens"]["add"]) + response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["add"]) album = self.yt_brand.get_album(sample_album) self.assertTrue(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) - response = self.yt_brand.edit_song_library_status( - album["tracks"][0]["feedbackTokens"]["remove"]) + response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["remove"]) album = self.yt_brand.get_album(sample_album) self.assertFalse(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) @@ -487,8 +461,7 @@ def test_edit_song_library_status(self): def test_rate_playlist(self): response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "LIKE") self.assertIn("actions", response) - response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", - "INDIFFERENT") + response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "INDIFFERENT") self.assertIn("actions", response) def test_subscribe_artists(self): @@ -501,26 +474,20 @@ def test_subscribe_artists(self): def test_get_playlist_foreign(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") - playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", - limit=300, - suggestions_limit=7) - self.assertGreater(len(playlist['duration']), 5) + playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7) + self.assertGreater(len(playlist["duration"]), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) self.yt.get_playlist("RDATgXd-") self.assertGreaterEqual(len(playlist["tracks"]), 100) - playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", - limit=None, - related=True) + playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", limit=None, related=True) self.assertGreater(len(playlist["tracks"]), 200) self.assertEqual(len(playlist["related"]), 0) def test_get_playlist_owned(self): - playlist = self.yt_brand.get_playlist(config["playlists"]["own"], - related=True, - suggestions_limit=21) + playlist = self.yt_brand.get_playlist(config["playlists"]["own"], related=True, suggestions_limit=21) self.assertLess(len(playlist["tracks"]), 100) self.assertEqual(len(playlist["suggestions"]), 21) self.assertEqual(len(playlist["related"]), 10) @@ -624,8 +591,7 @@ def test_get_library_upload_album(self): self.assertGreater(len(album["tracks"]), 0) def test_get_library_upload_artist(self): - tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], - 100) + tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], 100) self.assertGreater(len(tracks), 0) diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py index 63322764..4f3ce510 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -1,6 +1,7 @@ -from ytmusicapi.ytmusic import YTMusic +from importlib.metadata import PackageNotFoundError, version + from ytmusicapi.setup import setup, setup_oauth -from importlib.metadata import version, PackageNotFoundError +from ytmusicapi.ytmusic import YTMusic try: __version__ = version("ytmusicapi") @@ -8,7 +9,7 @@ # package is not installed pass -__copyright__ = 'Copyright 2023 sigma67' -__license__ = 'MIT' -__title__ = 'ytmusicapi' +__copyright__ = "Copyright 2023 sigma67" +__license__ = "MIT" +__title__ = "ytmusicapi" __all__ = ["YTMusic", "setup_oauth", "setup"] diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py index 6f55218f..34dda063 100644 --- a/ytmusicapi/auth/browser.py +++ b/ytmusicapi/auth/browser.py @@ -50,7 +50,8 @@ def setup_browser(filepath=None, headers_raw=None): missing_headers = {"cookie", "x-goog-authuser"} - set(k.lower() for k in user_headers.keys()) if missing_headers: raise Exception( - "The following entries are missing in your headers: " + ", ".join(missing_headers) + "The following entries are missing in your headers: " + + ", ".join(missing_headers) + ". Please try a different request (such as /browse) and make sure you are logged in." ) diff --git a/ytmusicapi/auth/oauth/__init__.py b/ytmusicapi/auth/oauth/__init__.py index d0b86c6a..f84e63fe 100644 --- a/ytmusicapi/auth/oauth/__init__.py +++ b/ytmusicapi/auth/oauth/__init__.py @@ -1,5 +1,5 @@ +from .base import OAuthToken from .credentials import OAuthCredentials from .refreshing import RefreshingToken -from .base import OAuthToken -__all__ = ['OAuthCredentials', 'RefreshingToken', 'OAuthToken'] +__all__ = ["OAuthCredentials", "RefreshingToken", "OAuthToken"] diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py index 980be2a3..c92c4362 100644 --- a/ytmusicapi/auth/oauth/base.py +++ b/ytmusicapi/auth/oauth/base.py @@ -1,14 +1,15 @@ -from typing import Optional, Dict -import time import json +import time +from typing import Dict, Optional from requests.structures import CaseInsensitiveDict -from .models import BaseTokenDict, DefaultScope, Bearer, RefreshableTokenDict +from .models import BaseTokenDict, Bearer, DefaultScope, RefreshableTokenDict class Credentials: - """ Base class representation of YouTubeMusicAPI OAuth Credentials """ + """Base class representation of YouTubeMusicAPI OAuth Credentials""" + client_id: str client_secret: str @@ -23,7 +24,8 @@ def refresh_token(self, refresh_token: str) -> BaseTokenDict: class Token: - """ Base class representation of the YouTubeMusicAPI OAuth token. """ + """Base class representation of the YouTubeMusicAPI OAuth token.""" + access_token: str refresh_token: str expires_in: int @@ -34,38 +36,40 @@ class Token: token_type: Bearer def __repr__(self) -> str: - """ Readable version. """ - return f'{self.__class__.__name__}: {self.as_dict()}' + """Readable version.""" + return f"{self.__class__.__name__}: {self.as_dict()}" def as_dict(self) -> RefreshableTokenDict: - """ Returns dictionary containing underlying token values. """ + """Returns dictionary containing underlying token values.""" return { - 'access_token': self.access_token, - 'refresh_token': self.refresh_token, - 'scope': self.scope, - 'expires_at': self.expires_at, - 'expires_in': self.expires_in, - 'token_type': self.token_type + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "scope": self.scope, + "expires_at": self.expires_at, + "expires_in": self.expires_in, + "token_type": self.token_type, } def as_json(self) -> str: return json.dumps(self.as_dict()) def as_auth(self) -> str: - """ Returns Authorization header ready str of token_type and access_token. """ - return f'{self.token_type} {self.access_token}' + """Returns Authorization header ready str of token_type and access_token.""" + return f"{self.token_type} {self.access_token}" class OAuthToken(Token): - """ Wrapper for an OAuth token implementing expiration methods. """ - - def __init__(self, - access_token: str, - refresh_token: str, - scope: str, - token_type: str, - expires_at: Optional[int] = None, - expires_in: Optional[int] = None): + """Wrapper for an OAuth token implementing expiration methods.""" + + def __init__( + self, + access_token: str, + refresh_token: str, + scope: str, + token_type: str, + expires_at: Optional[int] = None, + expires_in: Optional[int] = None, + ): """ :param access_token: active oauth key @@ -102,8 +106,8 @@ def update(self, fresh_access: BaseTokenDict): expires_at attribute set using current epoch, avoid expiration desync by passing only recently requested tokens dicts or updating values to compensate. """ - self._access_token = fresh_access['access_token'] - self._expires_at = int(time.time() + fresh_access['expires_in']) + self._access_token = fresh_access["access_token"] + self._expires_at = int(time.time() + fresh_access["expires_in"]) @property def access_token(self) -> str: diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py index 2afef4cd..bdf12f76 100644 --- a/ytmusicapi/auth/oauth/credentials.py +++ b/ytmusicapi/auth/oauth/credentials.py @@ -3,13 +3,19 @@ import requests -from .models import RefreshableTokenDict, BaseTokenDict, AuthCodeDict -from .base import OAuthToken, Credentials -from .refreshing import RefreshingToken +from ytmusicapi.constants import ( + OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET, + OAUTH_CODE_URL, + OAUTH_SCOPE, + OAUTH_TOKEN_URL, + OAUTH_USER_AGENT, +) + +from .base import Credentials, OAuthToken from .exceptions import BadOAuthClient, UnauthorizedOAuthClient - -from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CODE_URL, - OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) +from .models import AuthCodeDict, BaseTokenDict, RefreshableTokenDict +from .refreshing import RefreshingToken class OAuthCredentials(Credentials): @@ -17,11 +23,13 @@ class OAuthCredentials(Credentials): Class for handling OAuth credential retrieval and refreshing. """ - def __init__(self, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - session: Optional[requests.Session] = None, - proxies: Optional[Dict] = None): + def __init__( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + session: Optional[requests.Session] = None, + proxies: Optional[Dict] = None, + ): """ :param client_id: Optional. Set the GoogleAPI client_id used for auth flows. Requires client_secret also be provided if set. @@ -32,7 +40,7 @@ def __init__(self, # id, secret should be None, None or str, str if not isinstance(client_id, type(client_secret)): raise KeyError( - 'OAuthCredential init failure. Provide both client_id and client_secret or neither.' + "OAuthCredential init failure. Provide both client_id and client_secret or neither." ) # bind instance to OAuth client for auth flows @@ -44,34 +52,34 @@ def __init__(self, self._session.proxies.update(proxies) def get_code(self) -> AuthCodeDict: - """ Method for obtaining a new user auth code. First step of token creation. """ + """Method for obtaining a new user auth code. First step of token creation.""" code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) return code_response.json() def _send_request(self, url, data): - """ Method for sending post requests with required client_id and User-Agent modifications """ + """Method for sending post requests with required client_id and User-Agent modifications""" data.update({"client_id": self.client_id}) response = self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) if response.status_code == 401: data = response.json() - issue = data.get('error') - if issue == 'unauthorized_client': - raise UnauthorizedOAuthClient( - 'Token refresh error. Most likely client/token mismatch.') + issue = data.get("error") + if issue == "unauthorized_client": + raise UnauthorizedOAuthClient("Token refresh error. Most likely client/token mismatch.") - elif issue == 'invalid_client': + elif issue == "invalid_client": raise BadOAuthClient( - 'OAuth client failure. Most likely client_id and client_secret mismatch or ' - 'YouTubeData API is not enabled.') + "OAuth client failure. Most likely client_id and client_secret mismatch or " + "YouTubeData API is not enabled." + ) else: raise Exception( - f'OAuth request error. status_code: {response.status_code}, url: {url}, content: {data}' + f"OAuth request error. status_code: {response.status_code}, url: {url}, content: {data}" ) return response def token_from_code(self, device_code: str) -> RefreshableTokenDict: - """ Method for verifying user auth code and conversion into a FullTokenDict. """ + """Method for verifying user auth code and conversion into a FullTokenDict.""" response = self._send_request( OAUTH_TOKEN_URL, data={ @@ -82,9 +90,7 @@ def token_from_code(self, device_code: str) -> RefreshableTokenDict: ) return response.json() - def prompt_for_token(self, - open_browser: bool = False, - to_file: Optional[str] = None) -> RefreshingToken: + def prompt_for_token(self, open_browser: bool = False, to_file: Optional[str] = None) -> RefreshingToken: """ Method for CLI token creation via user inputs. diff --git a/ytmusicapi/auth/oauth/models.py b/ytmusicapi/auth/oauth/models.py index d301602c..b22e802c 100644 --- a/ytmusicapi/auth/oauth/models.py +++ b/ytmusicapi/auth/oauth/models.py @@ -1,13 +1,13 @@ """models for oauth authentication""" -from typing import Union, Literal, TypedDict +from typing import Literal, TypedDict, Union -DefaultScope = Union[str, Literal['https://www.googleapis.com/auth/youtube']] -Bearer = Union[str, Literal['Bearer']] +DefaultScope = Union[str, Literal["https://www.googleapis.com/auth/youtube"]] +Bearer = Union[str, Literal["Bearer"]] class BaseTokenDict(TypedDict): - """ Limited token. Does not provide a refresh token. Commonly obtained via a token refresh. """ + """Limited token. Does not provide a refresh token. Commonly obtained via a token refresh.""" access_token: str #: str to be used in Authorization header expires_in: int #: seconds until expiration from request timestamp @@ -16,14 +16,14 @@ class BaseTokenDict(TypedDict): class RefreshableTokenDict(BaseTokenDict): - """ Entire token. Including refresh. Obtained through token setup. """ + """Entire token. Including refresh. Obtained through token setup.""" expires_at: int #: UNIX epoch timestamp in seconds refresh_token: str #: str used to obtain new access token upon expiration class AuthCodeDict(TypedDict): - """ Keys for the json object obtained via code response during auth flow. """ + """Keys for the json object obtained via code response during auth flow.""" device_code: str #: code obtained via user confirmation and oauth consent user_code: str #: alphanumeric code user is prompted to enter as confirmation. formatted as XXX-XXX-XXX. diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py index 0d77252f..2d48e8a5 100644 --- a/ytmusicapi/auth/oauth/refreshing.py +++ b/ytmusicapi/auth/oauth/refreshing.py @@ -1,9 +1,9 @@ -from typing import Optional -import os import json +import os +from typing import Optional -from .base import OAuthToken, Token, Credentials -from .models import RefreshableTokenDict, Bearer, DefaultScope +from .base import Credentials, OAuthToken, Token +from .models import Bearer, RefreshableTokenDict class RefreshingToken(Token): @@ -32,10 +32,7 @@ def from_file(cls, file_path: str, credentials: Credentials, sync=True): return cls(OAuthToken(**file_pack), credentials, file_path if sync else None) - def __init__(self, - token: OAuthToken, - credentials: Credentials, - local_cache: Optional[str] = None): + def __init__(self, token: OAuthToken, credentials: Credentials, local_cache: Optional[str] = None): """ :param token: Underlying Token being maintained. :param credentials: OAuth client being used for refreshing. @@ -56,7 +53,7 @@ def local_cache(self) -> str | None: @local_cache.setter def local_cache(self, path: str): - """ Update attribute and dump token to new path. """ + """Update attribute and dump token to new path.""" self._local_cache = path self.store_token() @@ -78,7 +75,7 @@ def store_token(self, path: Optional[str] = None) -> None: file_path = path if path else self.local_cache if file_path: - with open(file_path, encoding="utf8", mode='w') as file: + with open(file_path, encoding="utf8", mode="w") as file: json.dump(self.token.as_dict(), file, indent=True) @property diff --git a/ytmusicapi/constants.py b/ytmusicapi/constants.py index 86d8eeb0..30926806 100644 --- a/ytmusicapi/constants.py +++ b/ytmusicapi/constants.py @@ -1,21 +1,23 @@ -YTM_DOMAIN = 'https://music.youtube.com' -YTM_BASE_API = YTM_DOMAIN + '/youtubei/v1/' -YTM_PARAMS = '?alt=json' +YTM_DOMAIN = "https://music.youtube.com" +YTM_BASE_API = YTM_DOMAIN + "/youtubei/v1/" +YTM_PARAMS = "?alt=json" YTM_PARAMS_KEY = "&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" -USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0' +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0" +# fmt: off SUPPORTED_LANGUAGES = { - 'ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'ko', 'nl', 'pt', 'ru', 'tr', 'ur', 'zh_CN', - 'zh_TW' + "ar", "de", "en", "es", "fr", "hi", "it", "ja", "ko", "nl", "pt", "ru", "tr", "ur", "zh_CN", + "zh_TW" } SUPPORTED_LOCATIONS = { - 'AE', 'AR', 'AT', 'AU', 'AZ', 'BA', 'BD', 'BE', 'BG', 'BH', 'BO', 'BR', 'BY', 'CA', 'CH', 'CL', - 'CO', 'CR', 'CY', 'CZ', 'DE', 'DK', 'DO', 'DZ', 'EC', 'EE', 'EG', 'ES', 'FI', 'FR', 'GB', 'GE', - 'GH', 'GR', 'GT', 'HK', 'HN', 'HR', 'HU', 'ID', 'IE', 'IL', 'IN', 'IQ', 'IS', 'IT', 'JM', 'JO', - 'JP', 'KE', 'KH', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LI', 'LK', 'LT', 'LU', 'LV', 'LY', 'MA', 'ME', - 'MK', 'MT', 'MX', 'MY', 'NG', 'NI', 'NL', 'NO', 'NP', 'NZ', 'OM', 'PA', 'PE', 'PG', 'PH', 'PK', - 'PL', 'PR', 'PT', 'PY', 'QA', 'RO', 'RS', 'RU', 'SA', 'SE', 'SG', 'SI', 'SK', 'SN', 'SV', 'TH', - 'TN', 'TR', 'TW', 'TZ', 'UA', 'UG', 'US', 'UY', 'VE', 'VN', 'YE', 'ZA', 'ZW' + "AE", "AR", "AT", "AU", "AZ", "BA", "BD", "BE", "BG", "BH", "BO", "BR", "BY", "CA", "CH", "CL", + "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "DZ", "EC", "EE", "EG", "ES", "FI", "FR", "GB", "GE", + "GH", "GR", "GT", "HK", "HN", "HR", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", + "JP", "KE", "KH", "KR", "KW", "KZ", "LA", "LB", "LI", "LK", "LT", "LU", "LV", "LY", "MA", "ME", + "MK", "MT", "MX", "MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", + "PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK", "SN", "SV", "TH", + "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN", "YE", "ZA", "ZW" } +# fmt: on OAUTH_CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com" OAUTH_CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT" OAUTH_SCOPE = "https://www.googleapis.com/auth/youtube" diff --git a/ytmusicapi/continuations.py b/ytmusicapi/continuations.py index f0793941..20497983 100644 --- a/ytmusicapi/continuations.py +++ b/ytmusicapi/continuations.py @@ -1,20 +1,19 @@ from ytmusicapi.navigation import nav -def get_continuations(results, - continuation_type, - limit, - request_func, - parse_func, - ctoken_path="", - reloadable=False): +def get_continuations( + results, continuation_type, limit, request_func, parse_func, ctoken_path="", reloadable=False +): items = [] - while 'continuations' in results and (limit is None or len(items) < limit): - additionalParams = get_reloadable_continuation_params(results) if reloadable \ + while "continuations" in results and (limit is None or len(items) < limit): + additionalParams = ( + get_reloadable_continuation_params(results) + if reloadable else get_continuation_params(results, ctoken_path) + ) response = request_func(additionalParams) - if 'continuationContents' in response: - results = response['continuationContents'][continuation_type] + if "continuationContents" in response: + results = response["continuationContents"][continuation_type] else: break contents = get_continuation_contents(results, parse_func) @@ -25,42 +24,38 @@ def get_continuations(results, return items -def get_validated_continuations(results, - continuation_type, - limit, - per_page, - request_func, - parse_func, - ctoken_path=""): +def get_validated_continuations( + results, continuation_type, limit, per_page, request_func, parse_func, ctoken_path="" +): items = [] - while 'continuations' in results and len(items) < limit: + while "continuations" in results and len(items) < limit: additionalParams = get_continuation_params(results, ctoken_path) wrapped_parse_func = lambda raw_response: get_parsed_continuation_items( - raw_response, parse_func, continuation_type) + raw_response, parse_func, continuation_type + ) validate_func = lambda parsed: validate_response(parsed, per_page, limit, len(items)) - response = resend_request_until_parsed_response_is_valid(request_func, additionalParams, - wrapped_parse_func, validate_func, - 3) - results = response['results'] - items.extend(response['parsed']) + response = resend_request_until_parsed_response_is_valid( + request_func, additionalParams, wrapped_parse_func, validate_func, 3 + ) + results = response["results"] + items.extend(response["parsed"]) return items def get_parsed_continuation_items(response, parse_func, continuation_type): - results = response['continuationContents'][continuation_type] - return {'results': results, 'parsed': get_continuation_contents(results, parse_func)} + results = response["continuationContents"][continuation_type] + return {"results": results, "parsed": get_continuation_contents(results, parse_func)} -def get_continuation_params(results, ctoken_path=''): - ctoken = nav(results, - ['continuations', 0, 'next' + ctoken_path + 'ContinuationData', 'continuation']) +def get_continuation_params(results, ctoken_path=""): + ctoken = nav(results, ["continuations", 0, "next" + ctoken_path + "ContinuationData", "continuation"]) return get_continuation_string(ctoken) def get_reloadable_continuation_params(results): - ctoken = nav(results, ['continuations', 0, 'reloadContinuationData', 'continuation']) + ctoken = nav(results, ["continuations", 0, "reloadContinuationData", "continuation"]) return get_continuation_string(ctoken) @@ -69,22 +64,23 @@ def get_continuation_string(ctoken): def get_continuation_contents(continuation, parse_func): - for term in ['contents', 'items']: + for term in ["contents", "items"]: if term in continuation: return parse_func(continuation[term]) return [] -def resend_request_until_parsed_response_is_valid(request_func, request_additional_params, - parse_func, validate_func, max_retries): +def resend_request_until_parsed_response_is_valid( + request_func, request_additional_params, parse_func, validate_func, max_retries +): response = request_func(request_additional_params) parsed_object = parse_func(response) retry_counter = 0 while not validate_func(parsed_object) and retry_counter < max_retries: response = request_func(request_additional_params) attempt = parse_func(response) - if len(attempt['parsed']) > len(parsed_object['parsed']): + if len(attempt["parsed"]) > len(parsed_object["parsed"]): parsed_object = attempt retry_counter += 1 @@ -96,4 +92,4 @@ def validate_response(response, per_page, limit, current_count): expected_items_count = min(per_page, remaining_items_count) # response is invalid, if it has less items then minimal expected count - return len(response['parsed']) >= expected_items_count + return len(response["parsed"]) >= expected_items_count diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 9432a807..40eec562 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -1,11 +1,10 @@ -import re import json -from http.cookies import SimpleCookie -from hashlib import sha1 -import time import locale - +import re +import time import unicodedata +from hashlib import sha1 +from http.cookies import SimpleCookie from ytmusicapi.constants import * @@ -17,36 +16,36 @@ def initialize_headers(): "accept-encoding": "gzip, deflate", "content-type": "application/json", "content-encoding": "gzip", - "origin": YTM_DOMAIN + "origin": YTM_DOMAIN, } def initialize_context(): return { - 'context': { - 'client': { - 'clientName': 'WEB_REMIX', - 'clientVersion': '1.' + time.strftime("%Y%m%d", time.gmtime()) + '.01.00' + "context": { + "client": { + "clientName": "WEB_REMIX", + "clientVersion": "1." + time.strftime("%Y%m%d", time.gmtime()) + ".01.00", }, - 'user': {} + "user": {}, } } def get_visitor_id(request_func): response = request_func(YTM_DOMAIN) - matches = re.findall(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', response.text) + matches = re.findall(r"ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;", response.text) visitor_id = "" if len(matches) > 0: ytcfg = json.loads(matches[0]) - visitor_id = ytcfg.get('VISITOR_DATA') - return {'X-Goog-Visitor-Id': visitor_id} + visitor_id = ytcfg.get("VISITOR_DATA") + return {"X-Goog-Visitor-Id": visitor_id} def sapisid_from_cookie(raw_cookie): cookie = SimpleCookie() - cookie.load(raw_cookie.replace("\"", "")) - return cookie['__Secure-3PAPISID'].value + cookie.load(raw_cookie.replace('"', "")) + return cookie["__Secure-3PAPISID"].value # SAPISID Hash reverse engineered by @@ -54,24 +53,22 @@ def sapisid_from_cookie(raw_cookie): def get_authorization(auth): sha_1 = sha1() unix_timestamp = str(int(time.time())) - sha_1.update((unix_timestamp + ' ' + auth).encode('utf-8')) + sha_1.update((unix_timestamp + " " + auth).encode("utf-8")) return "SAPISIDHASH " + unix_timestamp + "_" + sha_1.hexdigest() def to_int(string): string = unicodedata.normalize("NFKD", string) - number_string = re.sub(r'\D', '', string) + number_string = re.sub(r"\D", "", string) try: int_value = locale.atoi(number_string) except ValueError: - number_string = number_string.replace(',', '') + number_string = number_string.replace(",", "") int_value = int(number_string) return int_value def sum_total_duration(item): - if 'tracks' not in item: + if "tracks" not in item: return 0 - return sum([ - track['duration_seconds'] if 'duration_seconds' in track else 0 for track in item['tracks'] - ]) + return sum([track["duration_seconds"] if "duration_seconds" in track else 0 for track in item["tracks"]]) diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py index a0c6d4cb..69627f04 100644 --- a/ytmusicapi/mixins/_utils.py +++ b/ytmusicapi/mixins/_utils.py @@ -3,36 +3,37 @@ def prepare_like_endpoint(rating): - if rating == 'LIKE': - return 'like/like' - elif rating == 'DISLIKE': - return 'like/dislike' - elif rating == 'INDIFFERENT': - return 'like/removelike' + if rating == "LIKE": + return "like/like" + elif rating == "DISLIKE": + return "like/dislike" + elif rating == "INDIFFERENT": + return "like/removelike" else: return None def validate_order_parameter(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] + orders = ["a_to_z", "z_to_a", "recently_added"] if order and order not in orders: raise Exception( "Invalid order provided. Please use one of the following orders or leave out the parameter: " - + ', '.join(orders)) + + ", ".join(orders) + ) def prepare_order_params(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] + orders = ["a_to_z", "z_to_a", "recently_added"] if order is not None: # determine order_params via `.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[1].itemSectionRenderer.header.itemSectionTabbedHeaderRenderer.endItems[1].dropdownRenderer.entries[].dropdownItemRenderer.onSelectCommand.browseEndpoint.params` of `/youtubei/v1/browse` response - order_params = ['ggMGKgQIARAA', 'ggMGKgQIARAB', 'ggMGKgQIABAB'] + order_params = ["ggMGKgQIARAA", "ggMGKgQIARAB", "ggMGKgQIABAB"] return order_params[orders.index(order)] def html_to_txt(html_text): tags = re.findall("<[^>]+>", html_text) for tag in tags: - html_text = html_text.replace(tag, '') + html_text = html_text.replace(tag, "") return html_text diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index c3cbdf3c..2c99c823 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,15 +1,16 @@ -from ._utils import get_datestamp +from typing import Dict, List + from ytmusicapi.continuations import get_continuations from ytmusicapi.helpers import YTM_DOMAIN, sum_total_duration -from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.albums import parse_album_header -from ytmusicapi.parsers.playlists import parse_playlist_items +from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.library import parse_albums -from typing import List, Dict +from ytmusicapi.parsers.playlists import parse_playlist_items +from ._utils import get_datestamp -class BrowsingMixin: +class BrowsingMixin: def get_home(self, limit=3) -> List[Dict]: """ Get the home page. @@ -96,23 +97,24 @@ def get_home(self, limit=3) -> List[Dict]: ] """ - endpoint = 'browse' + endpoint = "browse" body = {"browseId": "FEmusic_home"} response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) home = [] home.extend(parse_mixed_content(results)) - section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) - if 'continuations' in section_list: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + section_list = nav(response, SINGLE_COLUMN_TAB + ["sectionListRenderer"]) + if "continuations" in section_list: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_mixed_content(contents) home.extend( - get_continuations(section_list, 'sectionListContinuation', limit - len(home), - request_func, parse_func)) + get_continuations( + section_list, "sectionListContinuation", limit - len(home), request_func, parse_func + ) + ) return home @@ -210,36 +212,39 @@ def get_artist(self, channelId: str) -> Dict: """ if channelId.startswith("MPLA"): channelId = channelId[4:] - body = {'browseId': channelId} - endpoint = 'browse' + body = {"browseId": channelId} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - artist = {'description': None, 'views': None} - header = response['header']['musicImmersiveHeaderRenderer'] - artist['name'] = nav(header, TITLE_TEXT) + artist = {"description": None, "views": None} + header = response["header"]["musicImmersiveHeaderRenderer"] + artist["name"] = nav(header, TITLE_TEXT) descriptionShelf = find_object_by_key(results, DESCRIPTION_SHELF[0], is_key=True) if descriptionShelf: - artist['description'] = nav(descriptionShelf, DESCRIPTION) - artist['views'] = None if 'subheader' not in descriptionShelf else descriptionShelf[ - 'subheader']['runs'][0]['text'] - subscription_button = header['subscriptionButton']['subscribeButtonRenderer'] - artist['channelId'] = subscription_button['channelId'] - artist['shuffleId'] = nav(header, - ['playButton', 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, - True) - artist['radioId'] = nav(header, ['startRadioButton', 'buttonRenderer'] - + NAVIGATION_WATCH_PLAYLIST_ID, True) - artist['subscribers'] = nav(subscription_button, - ['subscriberCountText', 'runs', 0, 'text'], True) - artist['subscribed'] = subscription_button['subscribed'] - artist['thumbnails'] = nav(header, THUMBNAILS, True) - artist['songs'] = {'browseId': None} - if 'musicShelfRenderer' in results[0]: # API sometimes does not return songs + artist["description"] = nav(descriptionShelf, DESCRIPTION) + artist["views"] = ( + None + if "subheader" not in descriptionShelf + else descriptionShelf["subheader"]["runs"][0]["text"] + ) + subscription_button = header["subscriptionButton"]["subscribeButtonRenderer"] + artist["channelId"] = subscription_button["channelId"] + artist["shuffleId"] = nav( + header, ["playButton", "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True + ) + artist["radioId"] = nav( + header, ["startRadioButton", "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True + ) + artist["subscribers"] = nav(subscription_button, ["subscriberCountText", "runs", 0, "text"], True) + artist["subscribed"] = subscription_button["subscribed"] + artist["thumbnails"] = nav(header, THUMBNAILS, True) + artist["songs"] = {"browseId": None} + if "musicShelfRenderer" in results[0]: # API sometimes does not return songs musicShelf = nav(results[0], MUSIC_SHELF) - if 'navigationEndpoint' in nav(musicShelf, TITLE): - artist['songs']['browseId'] = nav(musicShelf, TITLE + NAVIGATION_BROWSE_ID) - artist['songs']['results'] = parse_playlist_items(musicShelf['contents']) + if "navigationEndpoint" in nav(musicShelf, TITLE): + artist["songs"]["browseId"] = nav(musicShelf, TITLE + NAVIGATION_BROWSE_ID) + artist["songs"]["results"] = parse_playlist_items(musicShelf["contents"]) artist.update(self.parser.parse_artist_contents(results)) return artist @@ -255,7 +260,7 @@ def get_artist_albums(self, channelId: str, params: str) -> List[Dict]: """ body = {"browseId": channelId, "params": params} - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM) results = nav(results, GRID_ITEMS, True) or nav(results, CAROUSEL_CONTENTS) @@ -311,10 +316,10 @@ def get_user(self, channelId: str) -> Dict: } } """ - endpoint = 'browse' + endpoint = "browse" body = {"browseId": channelId} response = self._send_request(endpoint, body) - user = {'name': nav(response, ['header', 'musicVisualHeaderRenderer'] + TITLE_TEXT)} + user = {"name": nav(response, ["header", "musicVisualHeaderRenderer"] + TITLE_TEXT)} results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) user.update(self.parser.parse_artist_contents(results)) return user @@ -329,8 +334,8 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: :return: List of user playlists in the format of :py:func:`get_library_playlists` """ - endpoint = 'browse' - body = {"browseId": channelId, 'params': params} + endpoint = "browse" + body = {"browseId": channelId, "params": params} response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + GRID_ITEMS) user_playlists = parse_content_list(results, parse_playlist) @@ -413,19 +418,19 @@ def get_album(self, browseId: str) -> Dict: "duration_seconds": 4657 } """ - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - album['tracks'] = parse_playlist_items(results['contents']) + album["tracks"] = parse_playlist_items(results["contents"]) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST + [1] + CAROUSEL, True) if results is not None: - album['other_versions'] = parse_content_list(results['contents'], parse_album) - album['duration_seconds'] = sum_total_duration(album) - for i, track in enumerate(album['tracks']): - album['tracks'][i]['album'] = album['title'] - album['tracks'][i]['artists'] = album['tracks'][i]['artists'] or album['artists'] + album["other_versions"] = parse_content_list(results["contents"], parse_album) + album["duration_seconds"] = sum_total_duration(album) + for i, track in enumerate(album["tracks"]): + album["tracks"][i]["album"] = album["title"] + album["tracks"][i]["artists"] = album["tracks"][i]["artists"] or album["artists"] return album @@ -600,22 +605,16 @@ def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: } """ - endpoint = 'player' + endpoint = "player" if not signatureTimestamp: signatureTimestamp = get_datestamp() - 1 params = { - "playbackContext": { - "contentPlaybackContext": { - "signatureTimestamp": signatureTimestamp - } - }, - "video_id": videoId + "playbackContext": {"contentPlaybackContext": {"signatureTimestamp": signatureTimestamp}}, + "video_id": videoId, } response = self._send_request(endpoint, params) - keys = [ - 'videoDetails', 'playabilityStatus', 'streamingData', 'microformat', 'playbackTracking' - ] + keys = ["videoDetails", "playabilityStatus", "streamingData", "microformat", "playbackTracking"] for k in list(response.keys()): if k not in keys: del response[k] @@ -698,8 +697,8 @@ def get_song_related(self, browseId: str): if not browseId: raise Exception("Invalid browseId provided.") - response = self._send_request('browse', {'browseId': browseId}) - sections = nav(response, ['contents'] + SECTION_LIST) + response = self._send_request("browse", {"browseId": browseId}) + sections = nav(response, ["contents"] + SECTION_LIST) return parse_mixed_content(sections) def get_lyrics(self, browseId: str) -> Dict: @@ -721,12 +720,13 @@ def get_lyrics(self, browseId: str) -> Dict: if not browseId: raise Exception("Invalid browseId provided. This song might not have lyrics.") - response = self._send_request('browse', {'browseId': browseId}) - lyrics['lyrics'] = nav(response, - ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + DESCRIPTION, - True) - lyrics['source'] = nav(response, ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF - + ['footer'] + RUN_TEXT, True) + response = self._send_request("browse", {"browseId": browseId}) + lyrics["lyrics"] = nav( + response, ["contents"] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + DESCRIPTION, True + ) + lyrics["source"] = nav( + response, ["contents"] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + ["footer"] + RUN_TEXT, True + ) return lyrics @@ -780,7 +780,7 @@ def get_tasteprofile(self) -> Dict: """ - response = self._send_request('browse', {'browseId': "FEmusic_tastebuilder"}) + response = self._send_request("browse", {"browseId": "FEmusic_tastebuilder"}) profiles = nav(response, TASTE_PROFILE_ITEMS) taste_profiles = {} @@ -789,7 +789,7 @@ def get_tasteprofile(self) -> Dict: artist = nav(item["tastebuilderItemRenderer"], TASTE_PROFILE_ARTIST)[0]["text"] taste_profiles[artist] = { "selectionValue": item["tastebuilderItemRenderer"]["selectionFormValue"], - "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"] + "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"], } return taste_profiles @@ -807,9 +807,8 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No if taste_profile is None: taste_profile = self.get_tasteprofile() formData = { - "impressionValues": - [taste_profile[profile]["impressionValue"] for profile in taste_profile], - "selectedValues": [] + "impressionValues": [taste_profile[profile]["impressionValue"] for profile in taste_profile], + "selectedValues": [], } for artist in artists: @@ -817,5 +816,5 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No raise Exception("The artist, {}, was not present in taste!".format(artist)) formData["selectedValues"].append(taste_profile[artist]["selectionValue"]) - body = {'browseId': "FEmusic_home", "formData": formData} - self._send_request('browse', body) + body = {"browseId": "FEmusic_home", "formData": formData} + self._send_request("browse", body) diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index 5429835b..c56266c2 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -1,9 +1,9 @@ +from typing import Dict, List + from ytmusicapi.parsers.explore import * -from typing import List, Dict class ExploreMixin: - def get_mood_categories(self) -> Dict: """ Fetch "Moods & Genres" categories from YouTube Music. @@ -50,15 +50,14 @@ def get_mood_categories(self) -> Dict: """ sections = {} - response = self._send_request('browse', {'browseId': 'FEmusic_moods_and_genres'}) + response = self._send_request("browse", {"browseId": "FEmusic_moods_and_genres"}) for section in nav(response, SINGLE_COLUMN_TAB + SECTION_LIST): - title = nav(section, GRID + ['header', 'gridHeaderRenderer'] + TITLE_TEXT) + title = nav(section, GRID + ["header", "gridHeaderRenderer"] + TITLE_TEXT) sections[title] = [] for category in nav(section, GRID_ITEMS): - sections[title].append({ - "title": nav(category, CATEGORY_TITLE), - "params": nav(category, CATEGORY_PARAMS) - }) + sections[title].append( + {"title": nav(category, CATEGORY_TITLE), "params": nav(category, CATEGORY_PARAMS)} + ) return sections @@ -71,25 +70,24 @@ def get_mood_playlists(self, params: str) -> List[Dict]: """ playlists = [] - response = self._send_request('browse', { - 'browseId': 'FEmusic_moods_and_genres_category', - 'params': params - }) + response = self._send_request( + "browse", {"browseId": "FEmusic_moods_and_genres_category", "params": params} + ) for section in nav(response, SINGLE_COLUMN_TAB + SECTION_LIST): path = [] - if 'gridRenderer' in section: + if "gridRenderer" in section: path = GRID_ITEMS - elif 'musicCarouselShelfRenderer' in section: + elif "musicCarouselShelfRenderer" in section: path = CAROUSEL_CONTENTS - elif 'musicImmersiveCarouselShelfRenderer' in section: - path = ['musicImmersiveCarouselShelfRenderer', 'contents'] + elif "musicImmersiveCarouselShelfRenderer" in section: + path = ["musicImmersiveCarouselShelfRenderer", "contents"] if len(path): results = nav(section, path) playlists += parse_content_list(results, parse_playlist) return playlists - def get_charts(self, country: str = 'ZZ') -> Dict: + def get_charts(self, country: str = "ZZ") -> Dict: """ Get latest charts data from YouTube Music: Top songs, top videos, top artists and top trending videos. Global charts have no Trending section, US charts have an extra Genres section with some Genre charts. @@ -189,58 +187,69 @@ def get_charts(self, country: str = 'ZZ') -> Dict: } """ - body = {'browseId': 'FEmusic_charts'} + body = {"browseId": "FEmusic_charts"} if country: - body['formData'] = {'selectedValues': [country]} + body["formData"] = {"selectedValues": [country]} - response = self._send_request('browse', body) + response = self._send_request("browse", body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - charts = {'countries': {}} + charts = {"countries": {}} menu = nav( - results[0], MUSIC_SHELF + [ - 'subheaders', 0, 'musicSideAlignedItemRenderer', 'startItems', 0, - 'musicSortFilterButtonRenderer' - ]) - charts['countries']['selected'] = nav(menu, TITLE) - charts['countries']['options'] = list( - filter(None, [ - nav(m, ['payload', 'musicFormBooleanChoice', 'opaqueToken'], True) - for m in nav(response, FRAMEWORK_MUTATIONS) - ])) - charts_categories = ['videos', 'artists'] - - has_genres = country == 'US' - has_trending = country != 'ZZ' + results[0], + MUSIC_SHELF + + [ + "subheaders", + 0, + "musicSideAlignedItemRenderer", + "startItems", + 0, + "musicSortFilterButtonRenderer", + ], + ) + charts["countries"]["selected"] = nav(menu, TITLE) + charts["countries"]["options"] = list( + filter( + None, + [ + nav(m, ["payload", "musicFormBooleanChoice", "opaqueToken"], True) + for m in nav(response, FRAMEWORK_MUTATIONS) + ], + ) + ) + charts_categories = ["videos", "artists"] + + has_genres = country == "US" + has_trending = country != "ZZ" # use result length to determine if songs category is present # could also be done via an is_premium attribute on YTMusic instance has_songs = (len(results) - 1) > (len(charts_categories) + has_genres + has_trending) if has_songs: - charts_categories.insert(0, 'songs') + charts_categories.insert(0, "songs") if has_genres: - charts_categories.append('genres') + charts_categories.append("genres") if has_trending: - charts_categories.append('trending') + charts_categories.append("trending") parse_chart = lambda i, parse_func, key: parse_content_list( - nav(results[i + has_songs], CAROUSEL_CONTENTS), parse_func, key) + nav(results[i + has_songs], CAROUSEL_CONTENTS), parse_func, key + ) for i, c in enumerate(charts_categories): charts[c] = { - 'playlist': nav(results[1 + i], CAROUSEL + CAROUSEL_TITLE + NAVIGATION_BROWSE_ID, - True) + "playlist": nav(results[1 + i], CAROUSEL + CAROUSEL_TITLE + NAVIGATION_BROWSE_ID, True) } if has_songs: - charts['songs'].update({'items': parse_chart(0, parse_chart_song, MRLIR)}) + charts["songs"].update({"items": parse_chart(0, parse_chart_song, MRLIR)}) - charts['videos']['items'] = parse_chart(1, parse_video, MTRIR) - charts['artists']['items'] = parse_chart(2, parse_chart_artist, MRLIR) + charts["videos"]["items"] = parse_chart(1, parse_video, MTRIR) + charts["artists"]["items"] = parse_chart(2, parse_chart_artist, MRLIR) if has_genres: - charts['genres'] = parse_chart(3, parse_playlist, MTRIR) + charts["genres"] = parse_chart(3, parse_playlist, MTRIR) if has_trending: - charts['trending']['items'] = parse_chart(3 + has_genres, parse_chart_trending, MRLIR) + charts["trending"]["items"] = parse_chart(3 + has_genres, parse_chart_trending, MRLIR) return charts diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 71b01bba..3633960e 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -1,13 +1,14 @@ from random import randint +from typing import Dict, List + from ytmusicapi.continuations import * -from ._utils import * from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.library import * -from typing import List, Dict +from ._utils import * -class LibraryMixin: +class LibraryMixin: def get_library_playlists(self, limit: int = 25) -> List[Dict]: """ Retrieves the playlists in the user's library. @@ -25,28 +26,26 @@ def get_library_playlists(self, limit: int = 25) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_liked_playlists'} - endpoint = 'browse' + body = {"browseId": "FEmusic_liked_playlists"} + endpoint = "browse" response = self._send_request(endpoint, body) results = get_library_contents(response, GRID) - playlists = parse_content_list(results['items'][1:], parse_playlist) + playlists = parse_content_list(results["items"][1:], parse_playlist) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_content_list(contents, parse_playlist) remaining_limit = None if limit is None else (limit - len(playlists)) playlists.extend( - get_continuations(results, 'gridContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "gridContinuation", remaining_limit, request_func, parse_func) + ) return playlists - def get_library_songs(self, - limit: int = 25, - validate_responses: bool = False, - order: str = None) -> List[Dict]: + def get_library_songs( + self, limit: int = 25, validate_responses: bool = False, order: str = None + ) -> List[Dict]: """ Gets the songs in the user's library (liked videos are not included). To get liked songs and videos, use :py:func:`get_liked_songs` @@ -58,11 +57,11 @@ def get_library_songs(self, :return: List of songs. Same format as :py:func:`get_playlist` """ self._check_auth() - body = {'browseId': 'FEmusic_liked_videos'} + body = {"browseId": "FEmusic_liked_videos"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" per_page = 25 request_func = lambda additionalParams: self._send_request(endpoint, body) @@ -73,32 +72,45 @@ def get_library_songs(self, if validate_responses: validate_func = lambda parsed: validate_response(parsed, per_page, limit, 0) - response = resend_request_until_parsed_response_is_valid(request_func, None, - parse_func, validate_func, 3) + response = resend_request_until_parsed_response_is_valid( + request_func, None, parse_func, validate_func, 3 + ) else: response = parse_func(request_func(None)) - results = response['results'] - songs = response['parsed'] + results = response["results"] + songs = response["parsed"] if songs is None: return [] - if 'continuations' in results: + if "continuations" in results: request_continuations_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + endpoint, body, additionalParams + ) parse_continuations_func = lambda contents: parse_playlist_items(contents) if validate_responses: songs.extend( - get_validated_continuations(results, 'musicShelfContinuation', - limit - len(songs), per_page, - request_continuations_func, - parse_continuations_func)) + get_validated_continuations( + results, + "musicShelfContinuation", + limit - len(songs), + per_page, + request_continuations_func, + parse_continuations_func, + ) + ) else: remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, - request_continuations_func, parse_continuations_func)) + get_continuations( + results, + "musicShelfContinuation", + remaining_limit, + request_continuations_func, + parse_continuations_func, + ) + ) return songs @@ -126,16 +138,16 @@ def get_library_albums(self, limit: int = 25, order: str = None) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_liked_albums'} + body = {"browseId": "FEmusic_liked_albums"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_albums( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -155,15 +167,15 @@ def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_library_corpus_track_artists'} + body = {"browseId": "FEmusic_library_corpus_track_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -174,15 +186,15 @@ def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[ :return: List of artists. Same format as :py:func:`get_library_artists` """ self._check_auth() - body = {'browseId': 'FEmusic_library_corpus_artists'} + body = {"browseId": "FEmusic_library_corpus_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_liked_songs(self, limit: int = 100) -> Dict: """ @@ -191,7 +203,7 @@ def get_liked_songs(self, limit: int = 100) -> Dict: :param limit: How many items to return. Default: 100 :return: List of playlistItem dictionaries. See :py:func:`get_playlist` """ - return self.get_playlist('LM', limit) + return self.get_playlist("LM", limit) def get_history(self) -> List[Dict]: """ @@ -202,20 +214,20 @@ def get_history(self) -> List[Dict]: The additional property ``feedbackToken`` can be used to remove items with :py:func:`remove_history_items` """ self._check_auth() - body = {'browseId': 'FEmusic_history'} - endpoint = 'browse' + body = {"browseId": "FEmusic_history"} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) songs = [] for content in results: - data = nav(content, MUSIC_SHELF + ['contents'], True) + data = nav(content, MUSIC_SHELF + ["contents"], True) if not data: - error = nav(content, ['musicNotifierShelfRenderer'] + TITLE, True) + error = nav(content, ["musicNotifierShelfRenderer"] + TITLE, True) raise Exception(error) menu_entries = [[-1] + MENU_SERVICE + FEEDBACK_TOKEN] songlist = parse_playlist_items(data, menu_entries) for song in songlist: - song['played'] = nav(content['musicShelfRenderer'], TITLE_TEXT) + song["played"] = nav(content["musicShelfRenderer"], TITLE_TEXT) songs.extend(songlist) return songs @@ -229,7 +241,7 @@ def add_history_item(self, song): :return: Full response. response.status_code is 204 if successful """ url = song["playbackTracking"]["videostatsPlaybackUrl"]["baseUrl"] - CPNA = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + CPNA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" cpn = "".join((CPNA[randint(0, 256) & 63] for _ in range(0, 16))) params = {"ver": 2, "c": "WEB_REMIX", "cpn": cpn} return self._send_get_request(url, params) @@ -242,13 +254,13 @@ def remove_history_items(self, feedbackTokens: List[str]) -> Dict: # pragma: no :return: Full response """ self._check_auth() - body = {'feedbackTokens': feedbackTokens} - endpoint = 'feedback' + body = {"feedbackTokens": feedbackTokens} + endpoint = "feedback" response = self._send_request(endpoint, body) return response - def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: + def rate_song(self, videoId: str, rating: str = "INDIFFERENT") -> Dict: """ Rates a song ("thumbs up"/"thumbs down" interactions on YouTube Music) @@ -260,7 +272,7 @@ def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: :return: Full response """ self._check_auth() - body = {'target': {'videoId': videoId}} + body = {"target": {"videoId": videoId}} endpoint = prepare_like_endpoint(rating) if endpoint is None: return @@ -276,11 +288,11 @@ def edit_song_library_status(self, feedbackTokens: List[str] = None) -> Dict: :return: Full response """ self._check_auth() - body = {'feedbackTokens': feedbackTokens} - endpoint = 'feedback' + body = {"feedbackTokens": feedbackTokens} + endpoint = "feedback" return endpoint if not endpoint else self._send_request(endpoint, body) - def rate_playlist(self, playlistId: str, rating: str = 'INDIFFERENT') -> Dict: + def rate_playlist(self, playlistId: str, rating: str = "INDIFFERENT") -> Dict: """ Rates a playlist/album ("Add to library"/"Remove from library" interactions on YouTube Music) You can also dislike a playlist/album, which has an effect on your recommendations @@ -293,7 +305,7 @@ def rate_playlist(self, playlistId: str, rating: str = 'INDIFFERENT') -> Dict: :return: Full response """ self._check_auth() - body = {'target': {'playlistId': playlistId}} + body = {"target": {"playlistId": playlistId}} endpoint = prepare_like_endpoint(rating) return endpoint if not endpoint else self._send_request(endpoint, body) @@ -305,8 +317,8 @@ def subscribe_artists(self, channelIds: List[str]) -> Dict: :return: Full response """ self._check_auth() - body = {'channelIds': channelIds} - endpoint = 'subscription/subscribe' + body = {"channelIds": channelIds} + endpoint = "subscription/subscribe" return self._send_request(endpoint, body) def unsubscribe_artists(self, channelIds: List[str]) -> Dict: @@ -317,6 +329,6 @@ def unsubscribe_artists(self, channelIds: List[str]) -> Dict: :return: Full response """ self._check_auth() - body = {'channelIds': channelIds} - endpoint = 'subscription/unsubscribe' + body = {"channelIds": channelIds} + endpoint = "subscription/unsubscribe" return self._send_request(endpoint, body) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 997918c9..a7bcc0c4 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -1,20 +1,18 @@ -from typing import Dict, Union, Tuple, Optional -from ._utils import * +from typing import Dict, Optional, Tuple, Union from ytmusicapi.continuations import * +from ytmusicapi.helpers import sum_total_duration, to_int from ytmusicapi.navigation import * -from ytmusicapi.helpers import to_int, sum_total_duration -from ytmusicapi.parsers.playlists import * from ytmusicapi.parsers.browsing import parse_content_list, parse_playlist +from ytmusicapi.parsers.playlists import * +from ._utils import * -class PlaylistsMixin: - def get_playlist(self, - playlistId: str, - limit: int = 100, - related: bool = False, - suggestions_limit: int = 0) -> Dict: +class PlaylistsMixin: + def get_playlist( + self, playlistId: str, limit: int = 100, related: bool = False, suggestions_limit: int = 0 + ) -> Dict: """ Returns a list of playlist items @@ -104,57 +102,55 @@ def get_playlist(self, needed for moving/removing playlist items """ browseId = "VL" + playlistId if not playlistId.startswith("VL") else playlistId - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) - results = nav(response, - SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ['musicPlaylistShelfRenderer']) - playlist = {'id': results['playlistId']} - own_playlist = 'musicEditablePlaylistDetailHeaderRenderer' in response['header'] + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ["musicPlaylistShelfRenderer"]) + playlist = {"id": results["playlistId"]} + own_playlist = "musicEditablePlaylistDetailHeaderRenderer" in response["header"] if not own_playlist: - header = response['header']['musicDetailHeaderRenderer'] - playlist['privacy'] = 'PUBLIC' + header = response["header"]["musicDetailHeaderRenderer"] + playlist["privacy"] = "PUBLIC" else: - header = response['header']['musicEditablePlaylistDetailHeaderRenderer'] - playlist['privacy'] = header['editHeader']['musicPlaylistEditHeaderRenderer'][ - 'privacy'] - header = header['header']['musicDetailHeaderRenderer'] + header = response["header"]["musicEditablePlaylistDetailHeaderRenderer"] + playlist["privacy"] = header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"] + header = header["header"]["musicDetailHeaderRenderer"] - playlist['title'] = nav(header, TITLE_TEXT) - playlist['thumbnails'] = nav(header, THUMBNAIL_CROPPED) + playlist["title"] = nav(header, TITLE_TEXT) + playlist["thumbnails"] = nav(header, THUMBNAIL_CROPPED) playlist["description"] = nav(header, DESCRIPTION, True) run_count = len(nav(header, SUBTITLE_RUNS)) if run_count > 1: - playlist['author'] = { - 'name': nav(header, SUBTITLE2), - 'id': nav(header, SUBTITLE_RUNS + [2] + NAVIGATION_BROWSE_ID, True) + playlist["author"] = { + "name": nav(header, SUBTITLE2), + "id": nav(header, SUBTITLE_RUNS + [2] + NAVIGATION_BROWSE_ID, True), } if run_count == 5: - playlist['year'] = nav(header, SUBTITLE3) + playlist["year"] = nav(header, SUBTITLE3) - playlist['views'] = None - playlist['duration'] = None - if 'runs' in header['secondSubtitle']: - second_subtitle_runs = header['secondSubtitle']['runs'] + playlist["views"] = None + playlist["duration"] = None + if "runs" in header["secondSubtitle"]: + second_subtitle_runs = header["secondSubtitle"]["runs"] has_views = (len(second_subtitle_runs) > 3) * 2 - playlist['views'] = None if not has_views else to_int(second_subtitle_runs[0]['text']) + playlist["views"] = None if not has_views else to_int(second_subtitle_runs[0]["text"]) has_duration = (len(second_subtitle_runs) > 1) * 2 - playlist['duration'] = None if not has_duration else second_subtitle_runs[ - has_views + has_duration]['text'] - song_count = second_subtitle_runs[has_views + 0]['text'].split(" ") + playlist["duration"] = ( + None if not has_duration else second_subtitle_runs[has_views + has_duration]["text"] + ) + song_count = second_subtitle_runs[has_views + 0]["text"].split(" ") song_count = to_int(song_count[0]) if len(song_count) > 1 else 0 else: - song_count = len(results['contents']) + song_count = len(results["contents"]) - playlist['trackCount'] = song_count + playlist["trackCount"] = song_count - request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams - ) + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) # suggestions and related are missing e.g. on liked songs - section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) - playlist['related'] = [] - if 'continuations' in section_list: + section_list = nav(response, SINGLE_COLUMN_TAB + ["sectionListRenderer"]) + playlist["related"] = [] + if "continuations" in section_list: additionalParams = get_continuation_params(section_list) if own_playlist and (suggestions_limit > 0 or related): parse_func = lambda results: parse_playlist_items(results) @@ -162,44 +158,52 @@ def get_playlist(self, continuation = nav(suggested, SECTION_LIST_CONTINUATION) additionalParams = get_continuation_params(continuation) suggestions_shelf = nav(continuation, CONTENT + MUSIC_SHELF) - playlist['suggestions'] = get_continuation_contents(suggestions_shelf, parse_func) + playlist["suggestions"] = get_continuation_contents(suggestions_shelf, parse_func) parse_func = lambda results: parse_playlist_items(results) - playlist['suggestions'].extend( - get_continuations(suggestions_shelf, - 'musicShelfContinuation', - suggestions_limit - len(playlist['suggestions']), - request_func, - parse_func, - reloadable=True)) + playlist["suggestions"].extend( + get_continuations( + suggestions_shelf, + "musicShelfContinuation", + suggestions_limit - len(playlist["suggestions"]), + request_func, + parse_func, + reloadable=True, + ) + ) if related: response = request_func(additionalParams) continuation = nav(response, SECTION_LIST_CONTINUATION, True) if continuation: parse_func = lambda results: parse_content_list(results, parse_playlist) - playlist['related'] = get_continuation_contents( - nav(continuation, CONTENT + CAROUSEL), parse_func) + playlist["related"] = get_continuation_contents( + nav(continuation, CONTENT + CAROUSEL), parse_func + ) - playlist['tracks'] = [] - if 'contents' in results: - playlist['tracks'] = parse_playlist_items(results['contents']) + playlist["tracks"] = [] + if "contents" in results: + playlist["tracks"] = parse_playlist_items(results["contents"]) parse_func = lambda contents: parse_playlist_items(contents) - if 'continuations' in results: - playlist['tracks'].extend( - get_continuations(results, 'musicPlaylistShelfContinuation', limit, - request_func, parse_func)) - - playlist['duration_seconds'] = sum_total_duration(playlist) + if "continuations" in results: + playlist["tracks"].extend( + get_continuations( + results, "musicPlaylistShelfContinuation", limit, request_func, parse_func + ) + ) + + playlist["duration_seconds"] = sum_total_duration(playlist) return playlist - def create_playlist(self, - title: str, - description: str, - privacy_status: str = "PRIVATE", - video_ids: List = None, - source_playlist: str = None) -> Union[str, Dict]: + def create_playlist( + self, + title: str, + description: str, + privacy_status: str = "PRIVATE", + video_ids: List = None, + source_playlist: str = None, + ) -> Union[str, Dict]: """ Creates a new empty playlist and returns its id. @@ -212,28 +216,30 @@ def create_playlist(self, """ self._check_auth() body = { - 'title': title, - 'description': html_to_txt(description), # YT does not allow HTML tags - 'privacyStatus': privacy_status + "title": title, + "description": html_to_txt(description), # YT does not allow HTML tags + "privacyStatus": privacy_status, } if video_ids is not None: - body['videoIds'] = video_ids + body["videoIds"] = video_ids if source_playlist is not None: - body['sourcePlaylistId'] = source_playlist + body["sourcePlaylistId"] = source_playlist - endpoint = 'playlist/create' + endpoint = "playlist/create" response = self._send_request(endpoint, body) - return response['playlistId'] if 'playlistId' in response else response - - def edit_playlist(self, - playlistId: str, - title: str = None, - description: str = None, - privacyStatus: str = None, - moveItem: Tuple[str, str] = None, - addPlaylistId: str = None, - addToTop: Optional[bool] = None) -> Union[str, Dict]: + return response["playlistId"] if "playlistId" in response else response + + def edit_playlist( + self, + playlistId: str, + title: str = None, + description: str = None, + privacyStatus: str = None, + moveItem: Tuple[str, str] = None, + addPlaylistId: str = None, + addToTop: Optional[bool] = None, + ) -> Union[str, Dict]: """ Edit title, description or privacyStatus of a playlist. You may also move an item within a playlist or append another playlist to this playlist. @@ -249,43 +255,39 @@ def edit_playlist(self, :return: Status String or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId)} + body = {"playlistId": validate_playlist_id(playlistId)} actions = [] if title: - actions.append({'action': 'ACTION_SET_PLAYLIST_NAME', 'playlistName': title}) + actions.append({"action": "ACTION_SET_PLAYLIST_NAME", "playlistName": title}) if description: - actions.append({ - 'action': 'ACTION_SET_PLAYLIST_DESCRIPTION', - 'playlistDescription': description - }) + actions.append({"action": "ACTION_SET_PLAYLIST_DESCRIPTION", "playlistDescription": description}) if privacyStatus: - actions.append({ - 'action': 'ACTION_SET_PLAYLIST_PRIVACY', - 'playlistPrivacy': privacyStatus - }) + actions.append({"action": "ACTION_SET_PLAYLIST_PRIVACY", "playlistPrivacy": privacyStatus}) if moveItem: - actions.append({ - 'action': 'ACTION_MOVE_VIDEO_BEFORE', - 'setVideoId': moveItem[0], - 'movedSetVideoIdSuccessor': moveItem[1] - }) + actions.append( + { + "action": "ACTION_MOVE_VIDEO_BEFORE", + "setVideoId": moveItem[0], + "movedSetVideoIdSuccessor": moveItem[1], + } + ) if addPlaylistId: - actions.append({'action': 'ACTION_ADD_PLAYLIST', 'addedFullListId': addPlaylistId}) + actions.append({"action": "ACTION_ADD_PLAYLIST", "addedFullListId": addPlaylistId}) if addToTop: - actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': 'true'}) + actions.append({"action": "ACTION_SET_ADD_TO_TOP", "addToTop": "true"}) if addToTop is not None: - actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': str(addToTop)}) + actions.append({"action": "ACTION_SET_ADD_TO_TOP", "addToTop": str(addToTop)}) - body['actions'] = actions - endpoint = 'browse/edit_playlist' + body["actions"] = actions + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response + return response["status"] if "status" in response else response def delete_playlist(self, playlistId: str) -> Union[str, Dict]: """ @@ -295,16 +297,18 @@ def delete_playlist(self, playlistId: str) -> Union[str, Dict]: :return: Status String or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId)} - endpoint = 'playlist/delete' + body = {"playlistId": validate_playlist_id(playlistId)} + endpoint = "playlist/delete" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response - - def add_playlist_items(self, - playlistId: str, - videoIds: List[str] = None, - source_playlist: str = None, - duplicates: bool = False) -> Union[str, Dict]: + return response["status"] if "status" in response else response + + def add_playlist_items( + self, + playlistId: str, + videoIds: List[str] = None, + source_playlist: str = None, + duplicates: bool = False, + ) -> Union[str, Dict]: """ Add songs to an existing playlist @@ -315,32 +319,28 @@ def add_playlist_items(self, :return: Status String and a dict containing the new setVideoId for each videoId or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId), 'actions': []} + body = {"playlistId": validate_playlist_id(playlistId), "actions": []} if not videoIds and not source_playlist: - raise Exception( - "You must provide either videoIds or a source_playlist to add to the playlist") + raise Exception("You must provide either videoIds or a source_playlist to add to the playlist") if videoIds: for videoId in videoIds: - action = {'action': 'ACTION_ADD_VIDEO', 'addedVideoId': videoId} + action = {"action": "ACTION_ADD_VIDEO", "addedVideoId": videoId} if duplicates: - action['dedupeOption'] = 'DEDUPE_OPTION_SKIP' - body['actions'].append(action) + action["dedupeOption"] = "DEDUPE_OPTION_SKIP" + body["actions"].append(action) if source_playlist: - body['actions'].append({ - 'action': 'ACTION_ADD_PLAYLIST', - 'addedFullListId': source_playlist - }) + body["actions"].append({"action": "ACTION_ADD_PLAYLIST", "addedFullListId": source_playlist}) # add an empty ACTION_ADD_VIDEO because otherwise # YTM doesn't return the dict that maps videoIds to their new setVideoIds if not videoIds: - body['actions'].append({'action': 'ACTION_ADD_VIDEO', 'addedVideoId': None}) + body["actions"].append({"action": "ACTION_ADD_VIDEO", "addedVideoId": None}) - endpoint = 'browse/edit_playlist' + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - if 'status' in response and 'SUCCEEDED' in response['status']: + if "status" in response and "SUCCEEDED" in response["status"]: result_dict = [ result_data.get("playlistEditVideoAddedResultData") for result_data in response.get("playlistEditResults", []) @@ -359,19 +359,20 @@ def remove_playlist_items(self, playlistId: str, videos: List[Dict]) -> Union[st :return: Status String or full response """ self._check_auth() - videos = list(filter(lambda x: 'videoId' in x and 'setVideoId' in x, videos)) + videos = list(filter(lambda x: "videoId" in x and "setVideoId" in x, videos)) if len(videos) == 0: - raise Exception( - "Cannot remove songs, because setVideoId is missing. Do you own this playlist?") + raise Exception("Cannot remove songs, because setVideoId is missing. Do you own this playlist?") - body = {'playlistId': validate_playlist_id(playlistId), 'actions': []} + body = {"playlistId": validate_playlist_id(playlistId), "actions": []} for video in videos: - body['actions'].append({ - 'setVideoId': video['setVideoId'], - 'removedVideoId': video['videoId'], - 'action': 'ACTION_REMOVE_VIDEO' - }) + body["actions"].append( + { + "setVideoId": video["setVideoId"], + "removedVideoId": video["videoId"], + "action": "ACTION_REMOVE_VIDEO", + } + ) - endpoint = 'browse/edit_playlist' + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response + return response["status"] if "status" in response else response diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 0eeac063..783a48b3 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -1,16 +1,18 @@ -from typing import List, Dict, Union +from typing import Dict, List, Union + from ytmusicapi.continuations import get_continuations from ytmusicapi.parsers.search import * class SearchMixin: - - def search(self, - query: str, - filter: str = None, - scope: str = None, - limit: int = 20, - ignore_spelling: bool = False) -> List[Dict]: + def search( + self, + query: str, + filter: str = None, + scope: str = None, + limit: int = 20, + ignore_spelling: bool = False, + ) -> List[Dict]: """ Search YouTube music Returns results within the provided category. @@ -130,79 +132,94 @@ def search(self, """ - body = {'query': query} - endpoint = 'search' + body = {"query": query} + endpoint = "search" search_results = [] filters = [ - 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', - 'videos', 'profiles', 'podcasts', 'episodes' + "albums", + "artists", + "playlists", + "community_playlists", + "featured_playlists", + "songs", + "videos", + "profiles", + "podcasts", + "episodes", ] if filter and filter not in filters: raise Exception( "Invalid filter provided. Please use one of the following filters or leave out the parameter: " - + ', '.join(filters)) + + ", ".join(filters) + ) - scopes = ['library', 'uploads'] + scopes = ["library", "uploads"] if scope and scope not in scopes: raise Exception( "Invalid scope provided. Please use one of the following scopes or leave out the parameter: " - + ', '.join(scopes)) + + ", ".join(scopes) + ) if scope == scopes[1] and filter: raise Exception( "No filter can be set when searching uploads. Please unset the filter parameter when scope is set to " - "uploads. ") + "uploads. " + ) if scope == scopes[0] and filter in filters[3:5]: - raise Exception(f"{filter} cannot be set when searching library. " - f"Please use one of the following filters or leave out the parameter: " - + ', '.join(filters[0:3] + filters[5:])) + raise Exception( + f"{filter} cannot be set when searching library. " + f"Please use one of the following filters or leave out the parameter: " + + ", ".join(filters[0:3] + filters[5:]) + ) params = get_search_params(filter, scope, ignore_spelling) if params: - body['params'] = params + body["params"] = params response = self._send_request(endpoint, body) # no results - if 'contents' not in response: + if "contents" not in response: return search_results - if 'tabbedSearchResultsRenderer' in response['contents']: + if "tabbedSearchResultsRenderer" in response["contents"]: tab_index = 0 if not scope or filter else scopes.index(scope) + 1 - results = response['contents']['tabbedSearchResultsRenderer']['tabs'][tab_index][ - 'tabRenderer']['content'] + results = response["contents"]["tabbedSearchResultsRenderer"]["tabs"][tab_index]["tabRenderer"][ + "content" + ] else: - results = response['contents'] + results = response["contents"] results = nav(results, SECTION_LIST) # no results - if len(results) == 1 and 'itemSectionRenderer' in results: + if len(results) == 1 and "itemSectionRenderer" in results: return search_results # set filter for parser - if filter and 'playlists' in filter: - filter = 'playlists' + if filter and "playlists" in filter: + filter = "playlists" elif scope == scopes[1]: filter = scopes[1] for res in results: - if 'musicCardShelfRenderer' in res: - top_result = parse_top_result(res['musicCardShelfRenderer'], - self.parser.get_search_result_types()) + if "musicCardShelfRenderer" in res: + top_result = parse_top_result( + res["musicCardShelfRenderer"], self.parser.get_search_result_types() + ) search_results.append(top_result) - if results := nav(res, ['musicCardShelfRenderer', 'contents'], True): + if results := nav(res, ["musicCardShelfRenderer", "contents"], True): category = None # category "more from youtube" is missing sometimes - if 'messageRenderer' in results[0]: - category = nav(results.pop(0), ['messageRenderer'] + TEXT_RUN_TEXT) + if "messageRenderer" in results[0]: + category = nav(results.pop(0), ["messageRenderer"] + TEXT_RUN_TEXT) type = None else: continue - elif 'musicShelfRenderer' in res: - results = res['musicShelfRenderer']['contents'] + elif "musicShelfRenderer" in res: + results = res["musicShelfRenderer"]["contents"] type_filter = filter category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) if not type_filter and scope == scopes[0]: @@ -214,8 +231,7 @@ def search(self, continue search_result_types = self.parser.get_search_result_types() - search_results.extend( - parse_search_results(results, search_result_types, type, category)) + search_results.extend(parse_search_results(results, search_result_types, type, category)) if filter: # if filter is set, there are continuations @@ -226,14 +242,18 @@ def parse_func(contents): return parse_search_results(contents, search_result_types, type, category) search_results.extend( - get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', - limit - len(search_results), request_func, parse_func)) + get_continuations( + res["musicShelfRenderer"], + "musicShelfContinuation", + limit - len(search_results), + request_func, + parse_func, + ) + ) return search_results - def get_search_suggestions(self, - query: str, - detailed_runs=False) -> Union[List[str], List[Dict]]: + def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List[str], List[Dict]]: """ Get Search Suggestions @@ -300,8 +320,8 @@ def get_search_suggestions(self, ] """ - body = {'input': query} - endpoint = 'music/get_search_suggestions' + body = {"input": query} + endpoint = "music/get_search_suggestions" response = self._send_request(endpoint, body) search_suggestions = parse_search_suggestions(response, detailed_runs) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index feab698b..1f87842d 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -1,21 +1,26 @@ -import requests import ntpath import os -from typing import List, Dict, Union +from typing import Dict, List, Union + +import requests -from ._utils import validate_order_parameter, prepare_order_params +from ytmusicapi.continuations import get_continuations from ytmusicapi.helpers import * from ytmusicapi.navigation import * -from ytmusicapi.continuations import get_continuations -from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists, get_library_contents, \ - pop_songs_random_mix from ytmusicapi.parsers.albums import parse_album_header +from ytmusicapi.parsers.library import ( + get_library_contents, + parse_library_albums, + parse_library_artists, + pop_songs_random_mix, +) from ytmusicapi.parsers.uploads import parse_uploaded_items + from ..auth.types import AuthType +from ._utils import prepare_order_params, validate_order_parameter class UploadsMixin: - def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[Dict]: """ Returns a list of uploaded songs @@ -40,7 +45,7 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D } """ self._check_auth() - endpoint = 'browse' + endpoint = "browse" body = {"browseId": "FEmusic_library_privately_owned_tracks"} validate_order_parameter(order) if order is not None: @@ -50,15 +55,16 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D if results is None: return [] pop_songs_random_mix(results) - songs = parse_uploaded_items(results['contents']) + songs = parse_uploaded_items(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_uploaded_items)) + get_continuations( + results, "musicShelfContinuation", remaining_limit, request_func, parse_uploaded_items + ) + ) return songs @@ -71,15 +77,15 @@ def get_library_upload_albums(self, limit: int = 25, order: str = None) -> List[ :return: List of albums as returned by :py:func:`get_library_albums` """ self._check_auth() - body = {'browseId': 'FEmusic_library_privately_owned_releases'} + body = {"browseId": "FEmusic_library_privately_owned_releases"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_albums( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -90,15 +96,15 @@ def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List :return: List of artists as returned by :py:func:`get_library_artists` """ self._check_auth() - body = {'browseId': 'FEmusic_library_privately_owned_artists'} + body = {"browseId": "FEmusic_library_privately_owned_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict]: """ @@ -128,23 +134,24 @@ def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict ] """ self._check_auth() - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - if len(results['contents']) > 1: - results['contents'].pop(0) + if len(results["contents"]) > 1: + results["contents"].pop(0) - items = parse_uploaded_items(results['contents']) + items = parse_uploaded_items(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_uploaded_items(contents) remaining_limit = None if limit is None else (limit - len(items)) items.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_func)) + get_continuations( + results, "musicShelfContinuation", remaining_limit, request_func, parse_func + ) + ) return items @@ -181,13 +188,13 @@ def get_library_upload_album(self, browseId: str) -> Dict: }, """ self._check_auth() - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - album['tracks'] = parse_uploaded_items(results['contents']) - album['duration_seconds'] = sum_total_duration(album) + album["tracks"] = parse_uploaded_items(results["contents"]) + album["duration_seconds"] = sum_total_duration(album) return album def upload_song(self, filepath: str) -> Union[str, requests.Response]: @@ -207,27 +214,29 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: if os.path.splitext(filepath)[1][1:] not in supported_filetypes: raise Exception( "The provided file type is not supported by YouTube Music. Supported file types are " - + ', '.join(supported_filetypes)) + + ", ".join(supported_filetypes) + ) headers = self.headers.copy() - upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers[ - 'x-goog-authuser'] + upload_url = ( + "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers["x-goog-authuser"] + ) filesize = os.path.getsize(filepath) - body = ("filename=" + ntpath.basename(filepath)).encode('utf-8') - headers.pop('content-encoding', None) - headers['content-type'] = 'application/x-www-form-urlencoded;charset=utf-8' - headers['X-Goog-Upload-Command'] = 'start' - headers['X-Goog-Upload-Header-Content-Length'] = str(filesize) - headers['X-Goog-Upload-Protocol'] = 'resumable' + body = ("filename=" + ntpath.basename(filepath)).encode("utf-8") + headers.pop("content-encoding", None) + headers["content-type"] = "application/x-www-form-urlencoded;charset=utf-8" + headers["X-Goog-Upload-Command"] = "start" + headers["X-Goog-Upload-Header-Content-Length"] = str(filesize) + headers["X-Goog-Upload-Protocol"] = "resumable" response = requests.post(upload_url, data=body, headers=headers, proxies=self.proxies) - headers['X-Goog-Upload-Command'] = 'upload, finalize' - headers['X-Goog-Upload-Offset'] = '0' - upload_url = response.headers['X-Goog-Upload-URL'] - with open(filepath, 'rb') as file: + headers["X-Goog-Upload-Command"] = "upload, finalize" + headers["X-Goog-Upload-Offset"] = "0" + upload_url = response.headers["X-Goog-Upload-URL"] + with open(filepath, "rb") as file: response = requests.post(upload_url, data=file, headers=headers, proxies=self.proxies) if response.status_code == 200: - return 'STATUS_SUCCEEDED' + return "STATUS_SUCCEEDED" else: return response @@ -240,14 +249,14 @@ def delete_upload_entity(self, entityId: str) -> Union[str, Dict]: # pragma: no :return: Status String or error """ self._check_auth() - endpoint = 'music/delete_privately_owned_entity' - if 'FEmusic_library_privately_owned_release_detail' in entityId: - entityId = entityId.replace('FEmusic_library_privately_owned_release_detail', '') + endpoint = "music/delete_privately_owned_entity" + if "FEmusic_library_privately_owned_release_detail" in entityId: + entityId = entityId.replace("FEmusic_library_privately_owned_release_detail", "") body = {"entityId": entityId} response = self._send_request(endpoint, body) - if 'error' not in response: - return 'STATUS_SUCCEEDED' + if "error" not in response: + return "STATUS_SUCCEEDED" else: - return response['error'] + return response["error"] diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 412c89dd..4e5fc37a 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Union +from typing import Dict, List, Union from ytmusicapi.continuations import get_continuations from ytmusicapi.parsers.playlists import validate_playlist_id @@ -6,12 +6,14 @@ class WatchMixin: - def get_watch_playlist(self, - videoId: str = None, - playlistId: str = None, - limit=25, - radio: bool = False, - shuffle: bool = False) -> Dict[str, Union[List[Dict]]]: + def get_watch_playlist( + self, + videoId: str = None, + playlistId: str = None, + limit=25, + radio: bool = False, + shuffle: bool = False, + ) -> Dict[str, Union[List[Dict]]]: """ Get a watch list of tracks. This watch playlist appears when you press play on a track in YouTube Music. @@ -101,59 +103,71 @@ def get_watch_playlist(self, """ body = { - 'enablePersistentPlaylistPanel': True, - 'isAudioOnly': True, - 'tunerSettingValue': 'AUTOMIX_SETTING_NORMAL' + "enablePersistentPlaylistPanel": True, + "isAudioOnly": True, + "tunerSettingValue": "AUTOMIX_SETTING_NORMAL", } if not videoId and not playlistId: raise Exception("You must provide either a video id, a playlist id, or both") if videoId: - body['videoId'] = videoId + body["videoId"] = videoId if not playlistId: playlistId = "RDAMVM" + videoId if not (radio or shuffle): - body['watchEndpointMusicSupportedConfigs'] = { - 'watchEndpointMusicConfig': { - 'hasPersistentPlaylistPanel': True, - 'musicVideoType': "MUSIC_VIDEO_TYPE_ATV", + body["watchEndpointMusicSupportedConfigs"] = { + "watchEndpointMusicConfig": { + "hasPersistentPlaylistPanel": True, + "musicVideoType": "MUSIC_VIDEO_TYPE_ATV", } } - body['playlistId'] = validate_playlist_id(playlistId) - is_playlist = body['playlistId'].startswith('PL') or \ - body['playlistId'].startswith('OLA') + body["playlistId"] = validate_playlist_id(playlistId) + is_playlist = body["playlistId"].startswith("PL") or body["playlistId"].startswith("OLA") if shuffle and playlistId is not None: - body['params'] = "wAEB8gECKAE%3D" + body["params"] = "wAEB8gECKAE%3D" if radio: - body['params'] = "wAEB" - endpoint = 'next' + body["params"] = "wAEB" + endpoint = "next" response = self._send_request(endpoint, body) - watchNextRenderer = nav(response, [ - 'contents', 'singleColumnMusicWatchNextResultsRenderer', 'tabbedRenderer', - 'watchNextTabbedResultsRenderer' - ]) + watchNextRenderer = nav( + response, + [ + "contents", + "singleColumnMusicWatchNextResultsRenderer", + "tabbedRenderer", + "watchNextTabbedResultsRenderer", + ], + ) lyrics_browse_id = get_tab_browse_id(watchNextRenderer, 1) related_browse_id = get_tab_browse_id(watchNextRenderer, 2) - results = nav(watchNextRenderer, - TAB_CONTENT + ['musicQueueRenderer', 'content', 'playlistPanelRenderer']) + results = nav( + watchNextRenderer, TAB_CONTENT + ["musicQueueRenderer", "content", "playlistPanelRenderer"] + ) playlist = next( filter( bool, map( - lambda x: nav(x, ['playlistPanelVideoRenderer'] + NAVIGATION_PLAYLIST_ID, True - ), results['contents'])), None) - tracks = parse_watch_playlist(results['contents']) + lambda x: nav(x, ["playlistPanelVideoRenderer"] + NAVIGATION_PLAYLIST_ID, True), + results["contents"], + ), + ), + None, + ) + tracks = parse_watch_playlist(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_watch_playlist(contents) tracks.extend( - get_continuations(results, 'playlistPanelContinuation', limit - len(tracks), - request_func, parse_func, '' if is_playlist else 'Radio')) + get_continuations( + results, + "playlistPanelContinuation", + limit - len(tracks), + request_func, + parse_func, + "" if is_playlist else "Radio", + ) + ) - return dict(tracks=tracks, - playlistId=playlist, - lyrics=lyrics_browse_id, - related=related_browse_id) + return dict(tracks=tracks, playlistId=playlist, lyrics=lyrics_browse_id, related=related_browse_id) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 3357f4d8..6b2bfd8c 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -1,72 +1,70 @@ # commonly used navigation paths -CONTENT = ['contents', 0] -RUN_TEXT = ['runs', 0, 'text'] -TAB_CONTENT = ['tabs', 0, 'tabRenderer', 'content'] -TAB_1_CONTENT = ['tabs', 1, 'tabRenderer', 'content'] -SINGLE_COLUMN = ['contents', 'singleColumnBrowseResultsRenderer'] +CONTENT = ["contents", 0] +RUN_TEXT = ["runs", 0, "text"] +TAB_CONTENT = ["tabs", 0, "tabRenderer", "content"] +TAB_1_CONTENT = ["tabs", 1, "tabRenderer", "content"] +SINGLE_COLUMN = ["contents", "singleColumnBrowseResultsRenderer"] SINGLE_COLUMN_TAB = SINGLE_COLUMN + TAB_CONTENT -SECTION_LIST = ['sectionListRenderer', 'contents'] -SECTION_LIST_ITEM = ['sectionListRenderer'] + CONTENT -ITEM_SECTION = ['itemSectionRenderer'] + CONTENT -MUSIC_SHELF = ['musicShelfRenderer'] -GRID = ['gridRenderer'] -GRID_ITEMS = GRID + ['items'] -MENU = ['menu', 'menuRenderer'] -MENU_ITEMS = MENU + ['items'] -MENU_LIKE_STATUS = MENU + ['topLevelButtons', 0, 'likeButtonRenderer', 'likeStatus'] -MENU_SERVICE = ['menuServiceItemRenderer', 'serviceEndpoint'] -TOGGLE_MENU = 'toggleMenuServiceItemRenderer' -PLAY_BUTTON = [ - 'overlay', 'musicItemThumbnailOverlayRenderer', 'content', 'musicPlayButtonRenderer' -] -NAVIGATION_BROWSE = ['navigationEndpoint', 'browseEndpoint'] -NAVIGATION_BROWSE_ID = NAVIGATION_BROWSE + ['browseId'] -PAGE_TYPE = [ - 'browseEndpointContextSupportedConfigs', 'browseEndpointContextMusicConfig', 'pageType' -] -WATCH_VIDEO_ID = ['watchEndpoint', 'videoId'] -NAVIGATION_VIDEO_ID = ['navigationEndpoint'] + WATCH_VIDEO_ID -QUEUE_VIDEO_ID = ['queueAddEndpoint','queueTarget','videoId'] -NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] -NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] +SECTION_LIST = ["sectionListRenderer", "contents"] +SECTION_LIST_ITEM = ["sectionListRenderer"] + CONTENT +ITEM_SECTION = ["itemSectionRenderer"] + CONTENT +MUSIC_SHELF = ["musicShelfRenderer"] +GRID = ["gridRenderer"] +GRID_ITEMS = GRID + ["items"] +MENU = ["menu", "menuRenderer"] +MENU_ITEMS = MENU + ["items"] +MENU_LIKE_STATUS = MENU + ["topLevelButtons", 0, "likeButtonRenderer", "likeStatus"] +MENU_SERVICE = ["menuServiceItemRenderer", "serviceEndpoint"] +TOGGLE_MENU = "toggleMenuServiceItemRenderer" +PLAY_BUTTON = ["overlay", "musicItemThumbnailOverlayRenderer", "content", "musicPlayButtonRenderer"] +NAVIGATION_BROWSE = ["navigationEndpoint", "browseEndpoint"] +NAVIGATION_BROWSE_ID = NAVIGATION_BROWSE + ["browseId"] +PAGE_TYPE = ["browseEndpointContextSupportedConfigs", "browseEndpointContextMusicConfig", "pageType"] +WATCH_VIDEO_ID = ["watchEndpoint", "videoId"] +NAVIGATION_VIDEO_ID = ["navigationEndpoint"] + WATCH_VIDEO_ID +QUEUE_VIDEO_ID = ["queueAddEndpoint", "queueTarget", "videoId"] +NAVIGATION_PLAYLIST_ID = ["navigationEndpoint", "watchEndpoint", "playlistId"] +NAVIGATION_WATCH_PLAYLIST_ID = ["navigationEndpoint", "watchPlaylistEndpoint", "playlistId"] NAVIGATION_VIDEO_TYPE = [ - 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', - 'musicVideoType' + "watchEndpoint", + "watchEndpointMusicSupportedConfigs", + "watchEndpointMusicConfig", + "musicVideoType", ] -TITLE = ['title', 'runs', 0] -TITLE_TEXT = ['title'] + RUN_TEXT -TEXT_RUNS = ['text', 'runs'] +TITLE = ["title", "runs", 0] +TITLE_TEXT = ["title"] + RUN_TEXT +TEXT_RUNS = ["text", "runs"] TEXT_RUN = TEXT_RUNS + [0] -TEXT_RUN_TEXT = TEXT_RUN + ['text'] -SUBTITLE = ['subtitle'] + RUN_TEXT -SUBTITLE_RUNS = ['subtitle', 'runs'] -SUBTITLE2 = SUBTITLE_RUNS + [2, 'text'] -SUBTITLE3 = SUBTITLE_RUNS + [4, 'text'] -THUMBNAIL = ['thumbnail', 'thumbnails'] -THUMBNAILS = ['thumbnail', 'musicThumbnailRenderer'] + THUMBNAIL -THUMBNAIL_RENDERER = ['thumbnailRenderer', 'musicThumbnailRenderer'] + THUMBNAIL -THUMBNAIL_CROPPED = ['thumbnail', 'croppedSquareThumbnailRenderer'] + THUMBNAIL -FEEDBACK_TOKEN = ['feedbackEndpoint', 'feedbackToken'] -BADGE_PATH = [0, 'musicInlineBadgeRenderer', 'accessibilityData', 'accessibilityData', 'label'] -BADGE_LABEL = ['badges'] + BADGE_PATH -SUBTITLE_BADGE_LABEL = ['subtitleBadges'] + BADGE_PATH -CATEGORY_TITLE = ['musicNavigationButtonRenderer', 'buttonText'] + RUN_TEXT -CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] -MRLIR = 'musicResponsiveListItemRenderer' -MTRIR = 'musicTwoRowItemRenderer' +TEXT_RUN_TEXT = TEXT_RUN + ["text"] +SUBTITLE = ["subtitle"] + RUN_TEXT +SUBTITLE_RUNS = ["subtitle", "runs"] +SUBTITLE2 = SUBTITLE_RUNS + [2, "text"] +SUBTITLE3 = SUBTITLE_RUNS + [4, "text"] +THUMBNAIL = ["thumbnail", "thumbnails"] +THUMBNAILS = ["thumbnail", "musicThumbnailRenderer"] + THUMBNAIL +THUMBNAIL_RENDERER = ["thumbnailRenderer", "musicThumbnailRenderer"] + THUMBNAIL +THUMBNAIL_CROPPED = ["thumbnail", "croppedSquareThumbnailRenderer"] + THUMBNAIL +FEEDBACK_TOKEN = ["feedbackEndpoint", "feedbackToken"] +BADGE_PATH = [0, "musicInlineBadgeRenderer", "accessibilityData", "accessibilityData", "label"] +BADGE_LABEL = ["badges"] + BADGE_PATH +SUBTITLE_BADGE_LABEL = ["subtitleBadges"] + BADGE_PATH +CATEGORY_TITLE = ["musicNavigationButtonRenderer", "buttonText"] + RUN_TEXT +CATEGORY_PARAMS = ["musicNavigationButtonRenderer", "clickCommand", "browseEndpoint", "params"] +MRLIR = "musicResponsiveListItemRenderer" +MTRIR = "musicTwoRowItemRenderer" TASTE_PROFILE_ITEMS = ["contents", "tastebuilderRenderer", "contents"] TASTE_PROFILE_ARTIST = ["title", "runs"] -SECTION_LIST_CONTINUATION = ['continuationContents', 'sectionListContinuation'] -MENU_PLAYLIST_ID = MENU_ITEMS + [0, 'menuNavigationItemRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID -HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] -DESCRIPTION_SHELF = ['musicDescriptionShelfRenderer'] -DESCRIPTION = ['description'] + RUN_TEXT -CAROUSEL = ['musicCarouselShelfRenderer'] -IMMERSIVE_CAROUSEL = ['musicImmersiveCarouselShelfRenderer'] -CAROUSEL_CONTENTS = CAROUSEL + ['contents'] -CAROUSEL_TITLE = ['header', 'musicCarouselShelfBasicHeaderRenderer'] + TITLE -CARD_SHELF_TITLE = ['header', 'musicCardShelfHeaderBasicRenderer'] + TITLE_TEXT -FRAMEWORK_MUTATIONS = ['frameworkUpdates', 'entityBatchUpdate', 'mutations'] +SECTION_LIST_CONTINUATION = ["continuationContents", "sectionListContinuation"] +MENU_PLAYLIST_ID = MENU_ITEMS + [0, "menuNavigationItemRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID +HEADER_DETAIL = ["header", "musicDetailHeaderRenderer"] +DESCRIPTION_SHELF = ["musicDescriptionShelfRenderer"] +DESCRIPTION = ["description"] + RUN_TEXT +CAROUSEL = ["musicCarouselShelfRenderer"] +IMMERSIVE_CAROUSEL = ["musicImmersiveCarouselShelfRenderer"] +CAROUSEL_CONTENTS = CAROUSEL + ["contents"] +CAROUSEL_TITLE = ["header", "musicCarouselShelfBasicHeaderRenderer"] + TITLE +CARD_SHELF_TITLE = ["header", "musicCardShelfHeaderBasicRenderer"] + TITLE_TEXT +FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"] def nav(root, items, none_if_absent=False): diff --git a/ytmusicapi/parsers/_utils.py b/ytmusicapi/parsers/_utils.py index 1200cd50..ccb2efff 100644 --- a/ytmusicapi/parsers/_utils.py +++ b/ytmusicapi/parsers/_utils.py @@ -4,19 +4,19 @@ def parse_menu_playlists(data, result): - watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), 'menuNavigationItemRenderer') - for item in [_x['menuNavigationItemRenderer'] for _x in watch_menu]: - icon = nav(item, ['icon', 'iconType']) - if icon == 'MUSIC_SHUFFLE': - watch_key = 'shuffleId' - elif icon == 'MIX': - watch_key = 'radioId' + watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), "menuNavigationItemRenderer") + for item in [_x["menuNavigationItemRenderer"] for _x in watch_menu]: + icon = nav(item, ["icon", "iconType"]) + if icon == "MUSIC_SHUFFLE": + watch_key = "shuffleId" + elif icon == "MIX": + watch_key = "radioId" else: continue - watch_id = nav(item, ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'], True) + watch_id = nav(item, ["navigationEndpoint", "watchPlaylistEndpoint", "playlistId"], True) if not watch_id: - watch_id = nav(item, ['navigationEndpoint', 'watchEndpoint', 'playlistId'], True) + watch_id = nav(item, ["navigationEndpoint", "watchEndpoint", "playlistId"], True) if watch_id: result[watch_key] = watch_id @@ -25,39 +25,43 @@ def get_item_text(item, index, run_index=0, none_if_absent=False): column = get_flex_column_item(item, index) if not column: return None - if none_if_absent and len(column['text']['runs']) < run_index + 1: + if none_if_absent and len(column["text"]["runs"]) < run_index + 1: return None - return column['text']['runs'][run_index]['text'] + return column["text"]["runs"][run_index]["text"] def get_flex_column_item(item, index): - if len(item['flexColumns']) <= index or \ - 'text' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] or \ - 'runs' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer']['text']: + if ( + len(item["flexColumns"]) <= index + or "text" not in item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"] + or "runs" not in item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"]["text"] + ): return None - return item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] + return item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"] def get_fixed_column_item(item, index): - if 'text' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] or \ - 'runs' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer']['text']: + if ( + "text" not in item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"] + or "runs" not in item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"]["text"] + ): return None - return item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] + return item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"] def get_browse_id(item, index): - if 'navigationEndpoint' not in item['text']['runs'][index]: + if "navigationEndpoint" not in item["text"]["runs"][index]: return None else: - return nav(item['text']['runs'][index], NAVIGATION_BROWSE_ID) + return nav(item["text"]["runs"][index], NAVIGATION_BROWSE_ID) def get_dot_separator_index(runs): index = len(runs) try: - index = runs.index({'text': ' • '}) + index = runs.index({"text": " • "}) except ValueError: len(runs) return index @@ -74,7 +78,7 @@ def parse_duration(duration): def i18n(method): @wraps(method) def _impl(self, *method_args, **method_kwargs): - method.__globals__['_'] = self.lang.gettext + method.__globals__["_"] = self.lang.gettext return method(self, *method_args, **method_kwargs) return _impl diff --git a/ytmusicapi/parsers/albums.py b/ytmusicapi/parsers/albums.py index 3c9201d3..2d7e6852 100644 --- a/ytmusicapi/parsers/albums.py +++ b/ytmusicapi/parsers/albums.py @@ -1,37 +1,36 @@ -from ._utils import * from ytmusicapi.helpers import to_int -from .songs import parse_song_runs, parse_like_status + +from ._utils import * +from .songs import parse_like_status, parse_song_runs def parse_album_header(response): header = nav(response, HEADER_DETAIL) album = { - 'title': nav(header, TITLE_TEXT), - 'type': nav(header, SUBTITLE), - 'thumbnails': nav(header, THUMBNAIL_CROPPED) + "title": nav(header, TITLE_TEXT), + "type": nav(header, SUBTITLE), + "thumbnails": nav(header, THUMBNAIL_CROPPED), } if "description" in header: album["description"] = header["description"]["runs"][0]["text"] - album_info = parse_song_runs(header['subtitle']['runs'][2:]) + album_info = parse_song_runs(header["subtitle"]["runs"][2:]) album.update(album_info) - if len(header['secondSubtitle']['runs']) > 1: - album['trackCount'] = to_int(header['secondSubtitle']['runs'][0]['text']) - album['duration'] = header['secondSubtitle']['runs'][2]['text'] + if len(header["secondSubtitle"]["runs"]) > 1: + album["trackCount"] = to_int(header["secondSubtitle"]["runs"][0]["text"]) + album["duration"] = header["secondSubtitle"]["runs"][2]["text"] else: - album['duration'] = header['secondSubtitle']['runs'][0]['text'] + album["duration"] = header["secondSubtitle"]["runs"][0]["text"] # add to library/uploaded menu = nav(header, MENU) - toplevel = menu['topLevelButtons'] - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, - True) - if not album['audioPlaylistId']: - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_PLAYLIST_ID, - True) - service = nav(toplevel, [1, 'buttonRenderer', 'defaultServiceEndpoint'], True) + toplevel = menu["topLevelButtons"] + album["audioPlaylistId"] = nav(toplevel, [0, "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True) + if not album["audioPlaylistId"]: + album["audioPlaylistId"] = nav(toplevel, [0, "buttonRenderer"] + NAVIGATION_PLAYLIST_ID, True) + service = nav(toplevel, [1, "buttonRenderer", "defaultServiceEndpoint"], True) if service: - album['likeStatus'] = parse_like_status(service) + album["likeStatus"] = parse_like_status(service) return album diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 5fefab0b..2348395d 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -1,5 +1,5 @@ -from .songs import * from ._utils import * +from .songs import * def parse_mixed_content(rows): @@ -7,15 +7,15 @@ def parse_mixed_content(rows): for row in rows: if DESCRIPTION_SHELF[0] in row: results = nav(row, DESCRIPTION_SHELF) - title = nav(results, ['header'] + RUN_TEXT) + title = nav(results, ["header"] + RUN_TEXT) contents = nav(results, DESCRIPTION) else: results = next(iter(row.values())) - if 'contents' not in results: + if "contents" not in results: continue - title = nav(results, CAROUSEL_TITLE + ['text']) + title = nav(results, CAROUSEL_TITLE + ["text"]) contents = [] - for result in results['contents']: + for result in results["contents"]: data = nav(result, [MTRIR], True) content = None if data: @@ -37,7 +37,7 @@ def parse_mixed_content(rows): contents.append(content) - items.append({'title': title, 'contents': contents}) + items.append({"title": title, "contents": contents}) return items @@ -51,51 +51,50 @@ def parse_content_list(results, parse_func, key=MTRIR): def parse_album(result): return { - 'title': nav(result, TITLE_TEXT), - 'year': nav(result, SUBTITLE2, True), - 'browseId': nav(result, TITLE + NAVIGATION_BROWSE_ID), - 'thumbnails': nav(result, THUMBNAIL_RENDERER), - 'isExplicit': nav(result, SUBTITLE_BADGE_LABEL, True) is not None + "title": nav(result, TITLE_TEXT), + "year": nav(result, SUBTITLE2, True), + "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), + "thumbnails": nav(result, THUMBNAIL_RENDERER), + "isExplicit": nav(result, SUBTITLE_BADGE_LABEL, True) is not None, } def parse_single(result): return { - 'title': nav(result, TITLE_TEXT), - 'year': nav(result, SUBTITLE, True), - 'browseId': nav(result, TITLE + NAVIGATION_BROWSE_ID), - 'thumbnails': nav(result, THUMBNAIL_RENDERER) + "title": nav(result, TITLE_TEXT), + "year": nav(result, SUBTITLE, True), + "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), + "thumbnails": nav(result, THUMBNAIL_RENDERER), } def parse_song(result): song = { - 'title': nav(result, TITLE_TEXT), - 'videoId': nav(result, NAVIGATION_VIDEO_ID), - 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), - 'thumbnails': nav(result, THUMBNAIL_RENDERER) + "title": nav(result, TITLE_TEXT), + "videoId": nav(result, NAVIGATION_VIDEO_ID), + "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), + "thumbnails": nav(result, THUMBNAIL_RENDERER), } song.update(parse_song_runs(nav(result, SUBTITLE_RUNS))) return song def parse_song_flat(data): - columns = [get_flex_column_item(data, i) for i in range(0, len(data['flexColumns']))] + columns = [get_flex_column_item(data, i) for i in range(0, len(data["flexColumns"]))] song = { - 'title': nav(columns[0], TEXT_RUN_TEXT), - 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), - 'artists': parse_song_artists(data, 1), - 'thumbnails': nav(data, THUMBNAILS), - 'isExplicit': nav(data, BADGE_LABEL, True) is not None + "title": nav(columns[0], TEXT_RUN_TEXT), + "videoId": nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), + "artists": parse_song_artists(data, 1), + "thumbnails": nav(data, THUMBNAILS), + "isExplicit": nav(data, BADGE_LABEL, True) is not None, } - if len(columns) > 2 and columns[2] is not None and 'navigationEndpoint' in nav( - columns[2], TEXT_RUN): - song['album'] = { - 'name': nav(columns[2], TEXT_RUN_TEXT), - 'id': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) + if len(columns) > 2 and columns[2] is not None and "navigationEndpoint" in nav(columns[2], TEXT_RUN): + song["album"] = { + "name": nav(columns[2], TEXT_RUN_TEXT), + "id": nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID), } else: - song['views'] = nav(columns[1], ['text', 'runs', -1, 'text']).split(' ')[0] + song["views"] = nav(columns[1], ["text", "runs", -1, "text"]).split(" ")[0] return song @@ -105,30 +104,31 @@ def parse_video(result): artists_len = get_dot_separator_index(runs) videoId = nav(result, NAVIGATION_VIDEO_ID, True) if not videoId: - videoId = next(id for entry in nav(result, MENU_ITEMS) - if nav(entry, MENU_SERVICE + QUEUE_VIDEO_ID, True)) + videoId = next( + id for entry in nav(result, MENU_ITEMS) if nav(entry, MENU_SERVICE + QUEUE_VIDEO_ID, True) + ) return { - 'title': nav(result, TITLE_TEXT), - 'videoId': videoId, - 'artists': parse_song_artists_runs(runs[:artists_len]), - 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), - 'thumbnails': nav(result, THUMBNAIL_RENDERER, True), - 'views': runs[-1]['text'].split(' ')[0] + "title": nav(result, TITLE_TEXT), + "videoId": videoId, + "artists": parse_song_artists_runs(runs[:artists_len]), + "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), + "views": runs[-1]["text"].split(" ")[0], } def parse_playlist(data): playlist = { - 'title': nav(data, TITLE_TEXT), - 'playlistId': nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], - 'thumbnails': nav(data, THUMBNAIL_RENDERER) + "title": nav(data, TITLE_TEXT), + "playlistId": nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], + "thumbnails": nav(data, THUMBNAIL_RENDERER), } - subtitle = data['subtitle'] - if 'runs' in subtitle: - playlist['description'] = "".join([run['text'] for run in subtitle['runs']]) - if len(subtitle['runs']) == 3 and re.search(r'\d+ ', nav(data, SUBTITLE2)): - playlist['count'] = nav(data, SUBTITLE2).split(' ')[0] - playlist['author'] = parse_song_artists_runs(subtitle['runs'][:1]) + subtitle = data["subtitle"] + if "runs" in subtitle: + playlist["description"] = "".join([run["text"] for run in subtitle["runs"]]) + if len(subtitle["runs"]) == 3 and re.search(r"\d+ ", nav(data, SUBTITLE2)): + playlist["count"] = nav(data, SUBTITLE2).split(" ")[0] + playlist["author"] = parse_song_artists_runs(subtitle["runs"][:1]) return playlist @@ -136,18 +136,18 @@ def parse_playlist(data): def parse_related_artist(data): subscribers = nav(data, SUBTITLE, True) if subscribers: - subscribers = subscribers.split(' ')[0] + subscribers = subscribers.split(" ")[0] return { - 'title': nav(data, TITLE_TEXT), - 'browseId': nav(data, TITLE + NAVIGATION_BROWSE_ID), - 'subscribers': subscribers, - 'thumbnails': nav(data, THUMBNAIL_RENDERER), + "title": nav(data, TITLE_TEXT), + "browseId": nav(data, TITLE + NAVIGATION_BROWSE_ID), + "subscribers": subscribers, + "thumbnails": nav(data, THUMBNAIL_RENDERER), } def parse_watch_playlist(data): return { - 'title': nav(data, TITLE_TEXT), - 'playlistId': nav(data, NAVIGATION_WATCH_PLAYLIST_ID), - 'thumbnails': nav(data, THUMBNAIL_RENDERER), + "title": nav(data, TITLE_TEXT), + "playlistId": nav(data, NAVIGATION_WATCH_PLAYLIST_ID), + "thumbnails": nav(data, THUMBNAIL_RENDERER), } diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index 6f1a371d..510b5a7a 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -1,6 +1,6 @@ from ytmusicapi.parsers.browsing import * -TRENDS = {'ARROW_DROP_UP': 'up', 'ARROW_DROP_DOWN': 'down', 'ARROW_CHART_NEUTRAL': 'neutral'} +TRENDS = {"ARROW_DROP_UP": "up", "ARROW_DROP_DOWN": "down", "ARROW_CHART_NEUTRAL": "neutral"} def parse_chart_song(data): @@ -12,13 +12,13 @@ def parse_chart_song(data): def parse_chart_artist(data): subscribers = get_flex_column_item(data, 1) if subscribers: - subscribers = nav(subscribers, TEXT_RUN_TEXT).split(' ')[0] + subscribers = nav(subscribers, TEXT_RUN_TEXT).split(" ")[0] parsed = { - 'title': nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), - 'browseId': nav(data, NAVIGATION_BROWSE_ID), - 'subscribers': subscribers, - 'thumbnails': nav(data, THUMBNAILS), + "title": nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), + "browseId": nav(data, NAVIGATION_BROWSE_ID), + "subscribers": subscribers, + "thumbnails": nav(data, THUMBNAILS), } parsed.update(parse_ranking(data)) return parsed @@ -29,22 +29,21 @@ def parse_chart_trending(data): artists = parse_song_artists(data, 1) index = get_dot_separator_index(artists) # last item is views for some reason - views = None if index == len(artists) else artists.pop()['name'].split(' ')[0] + views = None if index == len(artists) else artists.pop()["name"].split(" ")[0] return { - 'title': nav(flex_0, TEXT_RUN_TEXT), - 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), - 'playlistId': nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID, True), - 'artists': artists, - 'thumbnails': nav(data, THUMBNAILS), - 'views': views + "title": nav(flex_0, TEXT_RUN_TEXT), + "videoId": nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), + "playlistId": nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID, True), + "artists": artists, + "thumbnails": nav(data, THUMBNAILS), + "views": views, } def parse_ranking(data): return { - 'rank': - nav(data, ['customIndexColumn', 'musicCustomIndexColumnRenderer'] + TEXT_RUN_TEXT), - 'trend': - TRENDS[nav(data, - ['customIndexColumn', 'musicCustomIndexColumnRenderer', 'icon', 'iconType'])] + "rank": nav(data, ["customIndexColumn", "musicCustomIndexColumnRenderer"] + TEXT_RUN_TEXT), + "trend": TRENDS[ + nav(data, ["customIndexColumn", "musicCustomIndexColumnRenderer", "icon", "iconType"]) + ], } diff --git a/ytmusicapi/parsers/i18n.py b/ytmusicapi/parsers/i18n.py index d6cf3d22..289a4571 100644 --- a/ytmusicapi/parsers/i18n.py +++ b/ytmusicapi/parsers/i18n.py @@ -1,45 +1,56 @@ -from typing import List, Dict +from typing import Dict, List -from ytmusicapi.navigation import nav, CAROUSEL, CAROUSEL_TITLE, NAVIGATION_BROWSE_ID +from ytmusicapi.navigation import CAROUSEL, CAROUSEL_TITLE, NAVIGATION_BROWSE_ID, nav from ytmusicapi.parsers._utils import i18n -from ytmusicapi.parsers.browsing import parse_album, parse_single, parse_video, parse_playlist, parse_related_artist, \ - parse_content_list +from ytmusicapi.parsers.browsing import ( + parse_album, + parse_content_list, + parse_playlist, + parse_related_artist, + parse_single, + parse_video, +) class Parser: - def __init__(self, language): self.lang = language @i18n def get_search_result_types(self): - return [_('artist'), _('playlist'), _('song'), _('video'), _('station'), _('profile'), _('podcast'), _('episode')] + return [ + _("artist"), + _("playlist"), + _("song"), + _("video"), + _("station"), + _("profile"), + _("podcast"), + _("episode"), + ] @i18n def parse_artist_contents(self, results: List) -> Dict: - categories = ['albums', 'singles', 'videos', 'playlists', 'related'] - categories_local = [_('albums'), _('singles'), _('videos'), _('playlists'), _('related')] - categories_parser = [ - parse_album, parse_single, parse_video, parse_playlist, parse_related_artist - ] + categories = ["albums", "singles", "videos", "playlists", "related"] + categories_local = [_("albums"), _("singles"), _("videos"), _("playlists"), _("related")] + categories_parser = [parse_album, parse_single, parse_video, parse_playlist, parse_related_artist] artist = {} for i, category in enumerate(categories): data = [ - r['musicCarouselShelfRenderer'] for r in results - if 'musicCarouselShelfRenderer' in r - and nav(r, CAROUSEL + CAROUSEL_TITLE)['text'].lower() == categories_local[i] + r["musicCarouselShelfRenderer"] + for r in results + if "musicCarouselShelfRenderer" in r + and nav(r, CAROUSEL + CAROUSEL_TITLE)["text"].lower() == categories_local[i] ] if len(data) > 0: - artist[category] = {'browseId': None, 'results': []} - if 'navigationEndpoint' in nav(data[0], CAROUSEL_TITLE): - artist[category]['browseId'] = nav(data[0], - CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) - if category in ['albums', 'singles', 'playlists']: - artist[category]['params'] = nav( - data[0], - CAROUSEL_TITLE)['navigationEndpoint']['browseEndpoint']['params'] - - artist[category]['results'] = parse_content_list(data[0]['contents'], - categories_parser[i]) + artist[category] = {"browseId": None, "results": []} + if "navigationEndpoint" in nav(data[0], CAROUSEL_TITLE): + artist[category]["browseId"] = nav(data[0], CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) + if category in ["albums", "singles", "playlists"]: + artist[category]["params"] = nav(data[0], CAROUSEL_TITLE)["navigationEndpoint"][ + "browseEndpoint" + ]["params"] + + artist[category]["results"] = parse_content_list(data[0]["contents"], categories_parser[i]) return artist diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 1c6e07d6..7cc64640 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -1,7 +1,8 @@ +from ytmusicapi.continuations import get_continuations + +from ._utils import * from .playlists import parse_playlist_items from .songs import parse_song_runs -from ._utils import * -from ytmusicapi.continuations import get_continuations def parse_artists(results, uploaded=False): @@ -9,16 +10,16 @@ def parse_artists(results, uploaded=False): for result in results: data = result[MRLIR] artist = {} - artist['browseId'] = nav(data, NAVIGATION_BROWSE_ID) - artist['artist'] = get_item_text(data, 0) + artist["browseId"] = nav(data, NAVIGATION_BROWSE_ID) + artist["artist"] = get_item_text(data, 0) parse_menu_playlists(data, artist) if uploaded: - artist['songs'] = get_item_text(data, 1).split(' ')[0] + artist["songs"] = get_item_text(data, 1).split(" ")[0] else: subtitle = get_item_text(data, 1) if subtitle: - artist['subscribers'] = subtitle.split(' ')[0] - artist['thumbnails'] = nav(data, THUMBNAILS, True) + artist["subscribers"] = subtitle.split(" ")[0] + artist["thumbnails"] = nav(data, THUMBNAILS, True) artists.append(artist) return artists @@ -28,14 +29,14 @@ def parse_library_albums(response, request_func, limit): results = get_library_contents(response, GRID) if results is None: return [] - albums = parse_albums(results['items']) + albums = parse_albums(results["items"]) - if 'continuations' in results: + if "continuations" in results: parse_func = lambda contents: parse_albums(contents) remaining_limit = None if limit is None else (limit - len(albums)) albums.extend( - get_continuations(results, 'gridContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "gridContinuation", remaining_limit, request_func, parse_func) + ) return albums @@ -45,14 +46,14 @@ def parse_albums(results): for result in results: data = result[MTRIR] album = {} - album['browseId'] = nav(data, TITLE + NAVIGATION_BROWSE_ID) - album['playlistId'] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) - album['title'] = nav(data, TITLE_TEXT) - album['thumbnails'] = nav(data, THUMBNAIL_RENDERER) + album["browseId"] = nav(data, TITLE + NAVIGATION_BROWSE_ID) + album["playlistId"] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) + album["title"] = nav(data, TITLE_TEXT) + album["thumbnails"] = nav(data, THUMBNAIL_RENDERER) - if 'runs' in data['subtitle']: - album['type'] = nav(data, SUBTITLE) - album.update(parse_song_runs(data['subtitle']['runs'][2:])) + if "runs" in data["subtitle"]: + album["type"] = nav(data, SUBTITLE) + album.update(parse_song_runs(data["subtitle"]["runs"][2:])) albums.append(album) @@ -63,14 +64,14 @@ def parse_library_artists(response, request_func, limit): results = get_library_contents(response, MUSIC_SHELF) if results is None: return [] - artists = parse_artists(results['contents']) + artists = parse_artists(results["contents"]) - if 'continuations' in results: + if "continuations" in results: parse_func = lambda contents: parse_artists(contents) remaining_limit = None if limit is None else (limit - len(artists)) artists.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "musicShelfContinuation", remaining_limit, request_func, parse_func) + ) return artists @@ -78,17 +79,14 @@ def parse_library_artists(response, request_func, limit): def pop_songs_random_mix(results) -> None: """remove the random mix that conditionally appears at the start of library songs""" if results: - if len(results['contents']) >= 2: - results['contents'].pop(0) + if len(results["contents"]) >= 2: + results["contents"].pop(0) def parse_library_songs(response): results = get_library_contents(response, MUSIC_SHELF) pop_songs_random_mix(results) - return { - 'results': results, - 'parsed': parse_playlist_items(results['contents']) if results else results - } + return {"results": results, "parsed": parse_playlist_items(results["contents"]) if results else results} def get_library_contents(response, renderer): @@ -103,10 +101,9 @@ def get_library_contents(response, renderer): section = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST, True) contents = None if section is None: # empty library - contents = nav(response, SINGLE_COLUMN + TAB_1_CONTENT + SECTION_LIST_ITEM + renderer, - True) + contents = nav(response, SINGLE_COLUMN + TAB_1_CONTENT + SECTION_LIST_ITEM + renderer, True) else: - results = find_object_by_key(section, 'itemSectionRenderer') + results = find_object_by_key(section, "itemSectionRenderer") if results is None: contents = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + renderer, True) else: diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 655f2660..a626f963 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -1,6 +1,7 @@ from typing import List -from .songs import * + from ._utils import * +from .songs import * def parse_playlist_items(results, menu_entries: List[List] = None): @@ -16,13 +17,17 @@ def parse_playlist_items(results, menu_entries: List[List] = None): library_status = None # if the item has a menu, find its setVideoId - if 'menu' in data: + if "menu" in data: for item in nav(data, MENU_ITEMS): - if 'menuServiceItemRenderer' in item: + if "menuServiceItemRenderer" in item: menu_service = nav(item, MENU_SERVICE) - if 'playlistEditEndpoint' in menu_service: - setVideoId = nav(menu_service, ['playlistEditEndpoint', 'actions', 0, 'setVideoId'], True) - videoId = nav(menu_service, ['playlistEditEndpoint', 'actions', 0, 'removedVideoId'], True) + if "playlistEditEndpoint" in menu_service: + setVideoId = nav( + menu_service, ["playlistEditEndpoint", "actions", 0, "setVideoId"], True + ) + videoId = nav( + menu_service, ["playlistEditEndpoint", "actions", 0, "removedVideoId"], True + ) if TOGGLE_MENU in item: feedback_tokens = parse_song_menu_tokens(item) @@ -30,15 +35,14 @@ def parse_playlist_items(results, menu_entries: List[List] = None): # if item is not playable, the videoId was retrieved above if nav(data, PLAY_BUTTON, none_if_absent=True) is not None: - if 'playNavigationEndpoint' in nav(data, PLAY_BUTTON): - videoId = nav(data, - PLAY_BUTTON)['playNavigationEndpoint']['watchEndpoint']['videoId'] + if "playNavigationEndpoint" in nav(data, PLAY_BUTTON): + videoId = nav(data, PLAY_BUTTON)["playNavigationEndpoint"]["watchEndpoint"]["videoId"] - if 'menu' in data: + if "menu" in data: like = nav(data, MENU_LIKE_STATUS, True) title = get_item_text(data, 0) - if title == 'Song deleted': + if title == "Song deleted": continue artists = parse_song_artists(data, 1) @@ -46,46 +50,49 @@ def parse_playlist_items(results, menu_entries: List[List] = None): album = parse_song_album(data, 2) duration = None - if 'fixedColumns' in data: - if 'simpleText' in get_fixed_column_item(data, 0)['text']: - duration = get_fixed_column_item(data, 0)['text']['simpleText'] + if "fixedColumns" in data: + if "simpleText" in get_fixed_column_item(data, 0)["text"]: + duration = get_fixed_column_item(data, 0)["text"]["simpleText"] else: - duration = get_fixed_column_item(data, 0)['text']['runs'][0]['text'] + duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] thumbnails = None - if 'thumbnail' in data: + if "thumbnail" in data: thumbnails = nav(data, THUMBNAILS) isAvailable = True - if 'musicItemRendererDisplayPolicy' in data: - isAvailable = data[ - 'musicItemRendererDisplayPolicy'] != 'MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT' + if "musicItemRendererDisplayPolicy" in data: + isAvailable = ( + data["musicItemRendererDisplayPolicy"] != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT" + ) isExplicit = nav(data, BADGE_LABEL, True) is not None videoType = nav( - data, MENU_ITEMS + [0, 'menuNavigationItemRenderer', 'navigationEndpoint'] - + NAVIGATION_VIDEO_TYPE, True) + data, + MENU_ITEMS + [0, "menuNavigationItemRenderer", "navigationEndpoint"] + NAVIGATION_VIDEO_TYPE, + True, + ) song = { - 'videoId': videoId, - 'title': title, - 'artists': artists, - 'album': album, - 'likeStatus': like, - 'inLibrary': library_status, - 'thumbnails': thumbnails, - 'isAvailable': isAvailable, - 'isExplicit': isExplicit, - 'videoType': videoType + "videoId": videoId, + "title": title, + "artists": artists, + "album": album, + "likeStatus": like, + "inLibrary": library_status, + "thumbnails": thumbnails, + "isAvailable": isAvailable, + "isExplicit": isExplicit, + "videoType": videoType, } if duration: - song['duration'] = duration - song['duration_seconds'] = parse_duration(duration) + song["duration"] = duration + song["duration_seconds"] = parse_duration(duration) if setVideoId: - song['setVideoId'] = setVideoId + song["setVideoId"] = setVideoId if feedback_tokens: - song['feedbackTokens'] = feedback_tokens + song["feedbackTokens"] = feedback_tokens if menu_entries: for menu_entry in menu_entries: diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 96d724b0..32488bae 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -1,15 +1,15 @@ -from .songs import * from ._utils import * +from .songs import * def get_search_result_type(result_type_local, result_types_local): if not result_type_local: return None - result_types = ['artist', 'playlist', 'song', 'video', 'station', 'profile', 'podcast', 'episode'] + result_types = ["artist", "playlist", "song", "video", "station", "profile", "podcast", "episode"] result_type_local = result_type_local.lower() # default to album since it's labeled with multiple values ('Single', 'EP', etc.) if result_type_local not in result_types_local: - result_type = 'album' + result_type = "album" else: result_type = result_types[result_types_local.index(result_type_local)] @@ -18,218 +18,214 @@ def get_search_result_type(result_type_local, result_types_local): def parse_top_result(data, search_result_types): result_type = get_search_result_type(nav(data, SUBTITLE), search_result_types) - search_result = {'category': nav(data, CARD_SHELF_TITLE), 'resultType': result_type} - if result_type == 'artist': + search_result = {"category": nav(data, CARD_SHELF_TITLE), "resultType": result_type} + if result_type == "artist": subscribers = nav(data, SUBTITLE2, True) if subscribers: - search_result['subscribers'] = subscribers.split(' ')[0] + search_result["subscribers"] = subscribers.split(" ")[0] - artist_info = parse_song_runs(nav(data, ['title', 'runs'])) + artist_info = parse_song_runs(nav(data, ["title", "runs"])) search_result.update(artist_info) - if result_type in ['song', 'video']: - on_tap = data.get('onTap') + if result_type in ["song", "video"]: + on_tap = data.get("onTap") if on_tap: - search_result['videoId'] = nav(on_tap, WATCH_VIDEO_ID) - search_result['videoType'] = nav(on_tap, NAVIGATION_VIDEO_TYPE) + search_result["videoId"] = nav(on_tap, WATCH_VIDEO_ID) + search_result["videoType"] = nav(on_tap, NAVIGATION_VIDEO_TYPE) - if result_type in ['song', 'video', 'album']: - search_result['title'] = nav(data, TITLE_TEXT) - runs = nav(data, ['subtitle', 'runs'])[2:] + if result_type in ["song", "video", "album"]: + search_result["title"] = nav(data, TITLE_TEXT) + runs = nav(data, ["subtitle", "runs"])[2:] song_info = parse_song_runs(runs) search_result.update(song_info) - if result_type in ['album']: - search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + if result_type in ["album"]: + search_result["browseId"] = nav(data, NAVIGATION_BROWSE_ID, True) - search_result['thumbnails'] = nav(data, THUMBNAILS, True) + search_result["thumbnails"] = nav(data, THUMBNAILS, True) return search_result def parse_search_result(data, search_result_types, result_type, category): default_offset = (not result_type or result_type == "album") * 2 - search_result = {'category': category} - video_type = nav(data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + search_result = {"category": category} + video_type = nav(data, PLAY_BUTTON + ["playNavigationEndpoint"] + NAVIGATION_VIDEO_TYPE, True) if not result_type and video_type: - result_type = 'song' if video_type == 'MUSIC_VIDEO_TYPE_ATV' else 'video' + result_type = "song" if video_type == "MUSIC_VIDEO_TYPE_ATV" else "video" - result_type = get_search_result_type(get_item_text(data, 1), - search_result_types) if not result_type else result_type - search_result['resultType'] = result_type + result_type = ( + get_search_result_type(get_item_text(data, 1), search_result_types) + if not result_type + else result_type + ) + search_result["resultType"] = result_type - if result_type != 'artist': - search_result['title'] = get_item_text(data, 0) + if result_type != "artist": + search_result["title"] = get_item_text(data, 0) - if result_type == 'artist': - search_result['artist'] = get_item_text(data, 0) + if result_type == "artist": + search_result["artist"] = get_item_text(data, 0) parse_menu_playlists(data, search_result) - elif result_type == 'album': - search_result['type'] = get_item_text(data, 1) + elif result_type == "album": + search_result["type"] = get_item_text(data, 1) - elif result_type == 'playlist': - flex_item = get_flex_column_item(data, 1)['text']['runs'] + elif result_type == "playlist": + flex_item = get_flex_column_item(data, 1)["text"]["runs"] has_author = len(flex_item) == default_offset + 3 - search_result['itemCount'] = get_item_text(data, 1, - default_offset + has_author * 2).split(' ')[0] - search_result['author'] = None if not has_author else get_item_text( - data, 1, default_offset) + search_result["itemCount"] = get_item_text(data, 1, default_offset + has_author * 2).split(" ")[0] + search_result["author"] = None if not has_author else get_item_text(data, 1, default_offset) - elif result_type == 'station': - search_result['videoId'] = nav(data, NAVIGATION_VIDEO_ID) - search_result['playlistId'] = nav(data, NAVIGATION_PLAYLIST_ID) + elif result_type == "station": + search_result["videoId"] = nav(data, NAVIGATION_VIDEO_ID) + search_result["playlistId"] = nav(data, NAVIGATION_PLAYLIST_ID) - elif result_type == 'profile': - search_result['name'] = get_item_text(data, 1, 2, True) + elif result_type == "profile": + search_result["name"] = get_item_text(data, 1, 2, True) - elif result_type == 'song': - search_result['album'] = None - if 'menu' in data: + elif result_type == "song": + search_result["album"] = None + if "menu" in data: toggle_menu = find_object_by_key(nav(data, MENU_ITEMS), TOGGLE_MENU) if toggle_menu: - search_result['inLibrary'] = parse_song_library_status(toggle_menu) - search_result['feedbackTokens'] = parse_song_menu_tokens(toggle_menu) + search_result["inLibrary"] = parse_song_library_status(toggle_menu) + search_result["feedbackTokens"] = parse_song_menu_tokens(toggle_menu) - elif result_type == 'upload': + elif result_type == "upload": browse_id = nav(data, NAVIGATION_BROWSE_ID, True) if not browse_id: # song result - flex_items = [ - nav(get_flex_column_item(data, i), ['text', 'runs'], True) for i in range(2) - ] + flex_items = [nav(get_flex_column_item(data, i), ["text", "runs"], True) for i in range(2)] if flex_items[0]: - search_result['videoId'] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) - search_result['playlistId'] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, True) + search_result["videoId"] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) + search_result["playlistId"] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, True) if flex_items[1]: search_result.update(parse_song_runs(flex_items[1])) - search_result['resultType'] = 'song' + search_result["resultType"] = "song" else: # artist or album result - search_result['browseId'] = browse_id - if 'artist' in search_result['browseId']: - search_result['resultType'] = 'artist' + search_result["browseId"] = browse_id + if "artist" in search_result["browseId"]: + search_result["resultType"] = "artist" else: flex_item2 = get_flex_column_item(data, 1) - runs = [ - run['text'] for i, run in enumerate(flex_item2['text']['runs']) if i % 2 == 0 - ] + runs = [run["text"] for i, run in enumerate(flex_item2["text"]["runs"]) if i % 2 == 0] if len(runs) > 1: - search_result['artist'] = runs[1] + search_result["artist"] = runs[1] if len(runs) > 2: # date may be missing - search_result['releaseDate'] = runs[2] - search_result['resultType'] = 'album' - - if result_type in ['song', 'video']: - search_result['videoId'] = nav( - data, PLAY_BUTTON + ['playNavigationEndpoint', 'watchEndpoint', 'videoId'], True) - search_result['videoType'] = video_type - - if result_type in ['song', 'video', 'album']: - search_result['duration'] = None - search_result['year'] = None + search_result["releaseDate"] = runs[2] + search_result["resultType"] = "album" + + if result_type in ["song", "video"]: + search_result["videoId"] = nav( + data, PLAY_BUTTON + ["playNavigationEndpoint", "watchEndpoint", "videoId"], True + ) + search_result["videoType"] = video_type + + if result_type in ["song", "video", "album"]: + search_result["duration"] = None + search_result["year"] = None flex_item = get_flex_column_item(data, 1) - runs = flex_item['text']['runs'][default_offset:] + runs = flex_item["text"]["runs"][default_offset:] song_info = parse_song_runs(runs) search_result.update(song_info) - if result_type in ['artist', 'album', 'playlist', 'profile']: - search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + if result_type in ["artist", "album", "playlist", "profile"]: + search_result["browseId"] = nav(data, NAVIGATION_BROWSE_ID, True) - if result_type in ['song', 'album']: - search_result['isExplicit'] = nav(data, BADGE_LABEL, True) is not None + if result_type in ["song", "album"]: + search_result["isExplicit"] = nav(data, BADGE_LABEL, True) is not None - search_result['thumbnails'] = nav(data, THUMBNAILS, True) + search_result["thumbnails"] = nav(data, THUMBNAILS, True) return search_result def parse_search_results(results, search_result_types, resultType=None, category=None): return [ - parse_search_result(result[MRLIR], search_result_types, resultType, category) - for result in results + parse_search_result(result[MRLIR], search_result_types, resultType, category) for result in results ] def get_search_params(filter, scope, ignore_spelling): - filtered_param1 = 'EgWKAQ' + filtered_param1 = "EgWKAQ" params = None if filter is None and scope is None and not ignore_spelling: return params - if scope == 'uploads': - params = 'agIYAw%3D%3D' + if scope == "uploads": + params = "agIYAw%3D%3D" - if scope == 'library': + if scope == "library": if filter: param1 = filtered_param1 param2 = _get_param2(filter) - param3 = 'AWoKEAUQCRADEAoYBA%3D%3D' + param3 = "AWoKEAUQCRADEAoYBA%3D%3D" else: - params = 'agIYBA%3D%3D' + params = "agIYBA%3D%3D" if scope is None and filter: - if filter == 'playlists': - params = 'Eg-KAQwIABAAGAAgACgB' + if filter == "playlists": + params = "Eg-KAQwIABAAGAAgACgB" if not ignore_spelling: - params += 'MABqChAEEAMQCRAFEAo%3D' + params += "MABqChAEEAMQCRAFEAo%3D" else: - params += 'MABCAggBagoQBBADEAkQBRAK' + params += "MABCAggBagoQBBADEAkQBRAK" - elif 'playlists' in filter: - param1 = 'EgeKAQQoA' - if filter == 'featured_playlists': - param2 = 'Dg' + elif "playlists" in filter: + param1 = "EgeKAQQoA" + if filter == "featured_playlists": + param2 = "Dg" else: # community_playlists - param2 = 'EA' + param2 = "EA" if not ignore_spelling: - param3 = 'BagwQDhAKEAMQBBAJEAU%3D' + param3 = "BagwQDhAKEAMQBBAJEAU%3D" else: - param3 = 'BQgIIAWoMEA4QChADEAQQCRAF' + param3 = "BQgIIAWoMEA4QChADEAQQCRAF" else: param1 = filtered_param1 param2 = _get_param2(filter) if not ignore_spelling: - param3 = 'AWoMEA4QChADEAQQCRAF' + param3 = "AWoMEA4QChADEAQQCRAF" else: - param3 = 'AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D' + param3 = "AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D" if not scope and not filter and ignore_spelling: - params = 'EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D' + params = "EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D" return params if params else param1 + param2 + param3 def _get_param2(filter): filter_params = { - 'songs': 'II', - 'videos': 'IQ', - 'albums': 'IY', - 'artists': 'Ig', - 'playlists': 'Io', - 'profiles': 'JY', - 'podcasts': 'JQ', - 'episodes': 'JI' + "songs": "II", + "videos": "IQ", + "albums": "IY", + "artists": "Ig", + "playlists": "Io", + "profiles": "JY", + "podcasts": "JQ", + "episodes": "JI", } return filter_params[filter] def parse_search_suggestions(results, detailed_runs): - if not results.get('contents', [{}])[0].get('searchSuggestionsSectionRenderer', {}).get( - 'contents', []): + if not results.get("contents", [{}])[0].get("searchSuggestionsSectionRenderer", {}).get("contents", []): return [] - raw_suggestions = results['contents'][0]['searchSuggestionsSectionRenderer']['contents'] + raw_suggestions = results["contents"][0]["searchSuggestionsSectionRenderer"]["contents"] suggestions = [] for raw_suggestion in raw_suggestions: - suggestion_content = raw_suggestion['searchSuggestionRenderer'] + suggestion_content = raw_suggestion["searchSuggestionRenderer"] - text = suggestion_content['navigationEndpoint']['searchEndpoint']['query'] - runs = suggestion_content['suggestion']['runs'] + text = suggestion_content["navigationEndpoint"]["searchEndpoint"]["query"] + runs = suggestion_content["suggestion"]["runs"] if detailed_runs: - suggestions.append({'text': text, 'runs': runs}) + suggestions.append({"text": text, "runs": runs}) else: suggestions.append(text) diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index 1c65d733..9347b51a 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -1,70 +1,64 @@ -from ._utils import * import re +from ._utils import * + def parse_song_artists(data, index): flex_item = get_flex_column_item(data, index) if not flex_item: return None else: - runs = flex_item['text']['runs'] + runs = flex_item["text"]["runs"] return parse_song_artists_runs(runs) def parse_song_artists_runs(runs): artists = [] for j in range(int(len(runs) / 2) + 1): - artists.append({ - 'name': runs[j * 2]['text'], - 'id': nav(runs[j * 2], NAVIGATION_BROWSE_ID, True) - }) + artists.append({"name": runs[j * 2]["text"], "id": nav(runs[j * 2], NAVIGATION_BROWSE_ID, True)}) return artists def parse_song_runs(runs): - parsed = {'artists': []} + parsed = {"artists": []} for i, run in enumerate(runs): if i % 2: # uneven items are always separators continue - text = run['text'] - if 'navigationEndpoint' in run: # artist or album - item = {'name': text, 'id': nav(run, NAVIGATION_BROWSE_ID, True)} + text = run["text"] + if "navigationEndpoint" in run: # artist or album + item = {"name": text, "id": nav(run, NAVIGATION_BROWSE_ID, True)} - if item['id'] and (item['id'].startswith('MPRE') - or "release_detail" in item['id']): # album - parsed['album'] = item + if item["id"] and (item["id"].startswith("MPRE") or "release_detail" in item["id"]): # album + parsed["album"] = item else: # artist - parsed['artists'].append(item) + parsed["artists"].append(item) else: # note: YT uses non-breaking space \xa0 to separate number and magnitude if re.match(r"^\d([^ ])* [^ ]*$", text) and i > 0: - parsed['views'] = text.split(' ')[0] + parsed["views"] = text.split(" ")[0] elif re.match(r"^(\d+:)*\d+:\d+$", text): - parsed['duration'] = text - parsed['duration_seconds'] = parse_duration(text) + parsed["duration"] = text + parsed["duration_seconds"] = parse_duration(text) elif re.match(r"^\d{4}$", text): - parsed['year'] = text + parsed["year"] = text else: # artist without id - parsed['artists'].append({'name': text, 'id': None}) + parsed["artists"].append({"name": text, "id": None}) return parsed def parse_song_album(data, index): flex_item = get_flex_column_item(data, index) - return None if not flex_item else { - 'name': get_item_text(data, index), - 'id': get_browse_id(flex_item, 0) - } + return None if not flex_item else {"name": get_item_text(data, index), "id": get_browse_id(flex_item, 0)} def parse_song_library_status(item) -> bool: """Returns True if song is in the library""" - library_status = nav(item, [TOGGLE_MENU, 'defaultIcon', 'iconType'], True) + library_status = nav(item, [TOGGLE_MENU, "defaultIcon", "iconType"], True) return library_status == "LIBRARY_SAVED" @@ -72,16 +66,16 @@ def parse_song_library_status(item) -> bool: def parse_song_menu_tokens(item): toggle_menu = item[TOGGLE_MENU] - library_add_token = nav(toggle_menu, ['defaultServiceEndpoint'] + FEEDBACK_TOKEN, True) - library_remove_token = nav(toggle_menu, ['toggledServiceEndpoint'] + FEEDBACK_TOKEN, True) + library_add_token = nav(toggle_menu, ["defaultServiceEndpoint"] + FEEDBACK_TOKEN, True) + library_remove_token = nav(toggle_menu, ["toggledServiceEndpoint"] + FEEDBACK_TOKEN, True) in_library = parse_song_library_status(item) if in_library: library_add_token, library_remove_token = library_remove_token, library_add_token - return {'add': library_add_token, 'remove': library_remove_token} + return {"add": library_add_token, "remove": library_remove_token} def parse_like_status(service): - status = ['LIKE', 'INDIFFERENT'] - return status[status.index(service['likeEndpoint']['status']) - 1] + status = ["LIKE", "INDIFFERENT"] + return status[status.index(service["likeEndpoint"]["status"]) - 1] diff --git a/ytmusicapi/parsers/uploads.py b/ytmusicapi/parsers/uploads.py index 2c7e11ac..91023d7b 100644 --- a/ytmusicapi/parsers/uploads.py +++ b/ytmusicapi/parsers/uploads.py @@ -1,34 +1,35 @@ from ._utils import * -from .songs import parse_song_artists, parse_song_album +from .songs import parse_song_album, parse_song_artists def parse_uploaded_items(results): songs = [] for result in results: data = result[MRLIR] - if 'menu' not in data: + if "menu" not in data: continue - entityId = nav(data, MENU_ITEMS)[-1]['menuNavigationItemRenderer']['navigationEndpoint'][ - 'confirmDialogEndpoint']['content']['confirmDialogRenderer']['confirmButton'][ - 'buttonRenderer']['command']['musicDeletePrivatelyOwnedEntityCommand']['entityId'] + entityId = nav(data, MENU_ITEMS)[-1]["menuNavigationItemRenderer"]["navigationEndpoint"][ + "confirmDialogEndpoint" + ]["content"]["confirmDialogRenderer"]["confirmButton"]["buttonRenderer"]["command"][ + "musicDeletePrivatelyOwnedEntityCommand" + ]["entityId"] - videoId = nav(data, MENU_ITEMS + [0] - + MENU_SERVICE)['queueAddEndpoint']['queueTarget']['videoId'] + videoId = nav(data, MENU_ITEMS + [0] + MENU_SERVICE)["queueAddEndpoint"]["queueTarget"]["videoId"] title = get_item_text(data, 0) like = nav(data, MENU_LIKE_STATUS) - thumbnails = nav(data, THUMBNAILS) if 'thumbnail' in data else None - duration = get_fixed_column_item(data, 0)['text']['runs'][0]['text'] + thumbnails = nav(data, THUMBNAILS) if "thumbnail" in data else None + duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] song = { - 'entityId': entityId, - 'videoId': videoId, - 'title': title, - 'duration': duration, - 'duration_seconds': parse_duration(duration), - 'artists': parse_song_artists(data, 1), - 'album': parse_song_album(data, 2), - 'likeStatus': like, - 'thumbnails': thumbnails + "entityId": entityId, + "videoId": videoId, + "title": title, + "duration": duration, + "duration_seconds": parse_duration(duration), + "artists": parse_song_artists(data, 1), + "album": parse_song_album(data, 2), + "likeStatus": like, + "thumbnails": thumbnails, } songs.append(song) diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index d1a3cf5f..8f28dd6f 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -4,22 +4,22 @@ def parse_watch_playlist(results): tracks = [] - PPVWR = 'playlistPanelVideoWrapperRenderer' - PPVR = 'playlistPanelVideoRenderer' + PPVWR = "playlistPanelVideoWrapperRenderer" + PPVR = "playlistPanelVideoRenderer" for result in results: counterpart = None if PPVWR in result: - counterpart = result[PPVWR]['counterpart'][0]['counterpartRenderer'][PPVR] - result = result[PPVWR]['primaryRenderer'] + counterpart = result[PPVWR]["counterpart"][0]["counterpartRenderer"][PPVR] + result = result[PPVWR]["primaryRenderer"] if PPVR not in result: continue data = result[PPVR] - if 'unplayableText' in data: + if "unplayableText" in data: continue track = parse_watch_track(data) if counterpart: - track['counterpart'] = parse_watch_track(counterpart) + track["counterpart"] = parse_watch_track(counterpart) tracks.append(track) return tracks @@ -30,31 +30,30 @@ def parse_watch_track(data): for item in nav(data, MENU_ITEMS): if TOGGLE_MENU in item: library_status = parse_song_library_status(item) - service = item[TOGGLE_MENU]['defaultServiceEndpoint'] - if 'feedbackEndpoint' in service: + service = item[TOGGLE_MENU]["defaultServiceEndpoint"] + if "feedbackEndpoint" in service: feedback_tokens = parse_song_menu_tokens(item) - if 'likeEndpoint' in service: + if "likeEndpoint" in service: like_status = parse_like_status(service) - song_info = parse_song_runs(data['longBylineText']['runs']) + song_info = parse_song_runs(data["longBylineText"]["runs"]) track = { - 'videoId': data['videoId'], - 'title': nav(data, TITLE_TEXT), - 'length': nav(data, ['lengthText', 'runs', 0, 'text'], True), - 'thumbnail': nav(data, THUMBNAIL), - 'feedbackTokens': feedback_tokens, - 'likeStatus': like_status, - 'inLibrary': library_status, - 'videoType': nav(data, ['navigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + "videoId": data["videoId"], + "title": nav(data, TITLE_TEXT), + "length": nav(data, ["lengthText", "runs", 0, "text"], True), + "thumbnail": nav(data, THUMBNAIL), + "feedbackTokens": feedback_tokens, + "likeStatus": like_status, + "inLibrary": library_status, + "videoType": nav(data, ["navigationEndpoint"] + NAVIGATION_VIDEO_TYPE, True), } track.update(song_info) return track def get_tab_browse_id(watchNextRenderer, tab_id): - if 'unselectable' not in watchNextRenderer['tabs'][tab_id]['tabRenderer']: - return watchNextRenderer['tabs'][tab_id]['tabRenderer']['endpoint']['browseEndpoint'][ - 'browseId'] + if "unselectable" not in watchNextRenderer["tabs"][tab_id]["tabRenderer"]: + return watchNextRenderer["tabs"][tab_id]["tabRenderer"]["endpoint"]["browseEndpoint"]["browseId"] else: return None diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index f826ec41..67da6de9 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -22,12 +22,14 @@ def setup(filepath: str = None, headers_raw: str = None) -> Dict: return setup_browser(filepath, headers_raw) -def setup_oauth(filepath: str = None, - session: requests.Session = None, - proxies: dict = None, - open_browser: bool = False, - client_id: str = None, - client_secret: str = None) -> Dict: +def setup_oauth( + filepath: str = None, + session: requests.Session = None, + proxies: dict = None, + open_browser: bool = False, + client_id: str = None, + client_secret: str = None, +) -> Dict: """ Starts oauth flow from the terminal and returns a string that can be passed to YTMusic() @@ -55,11 +57,8 @@ def setup_oauth(filepath: str = None, def parse_args(args): - parser = argparse.ArgumentParser(description='Setup ytmusicapi.') - parser.add_argument("setup_type", - type=str, - choices=["oauth", "browser"], - help="choose a setup type.") + parser = argparse.ArgumentParser(description="Setup ytmusicapi.") + parser.add_argument("setup_type", type=str, choices=["oauth", "browser"], help="choose a setup type.") parser.add_argument("--file", type=Path, help="optional path to output file.") return parser.parse_args(args) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 75bf9408..870f4a24 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -1,43 +1,47 @@ -import requests import gettext import os -from functools import partial +import time from contextlib import suppress +from functools import partial from typing import Dict, Optional -import time +import requests from requests.structures import CaseInsensitiveDict -from ytmusicapi.parsers.i18n import Parser + from ytmusicapi.helpers import * from ytmusicapi.mixins.browsing import BrowsingMixin -from ytmusicapi.mixins.search import SearchMixin -from ytmusicapi.mixins.watch import WatchMixin from ytmusicapi.mixins.explore import ExploreMixin from ytmusicapi.mixins.library import LibraryMixin from ytmusicapi.mixins.playlists import PlaylistsMixin +from ytmusicapi.mixins.search import SearchMixin from ytmusicapi.mixins.uploads import UploadsMixin +from ytmusicapi.mixins.watch import WatchMixin +from ytmusicapi.parsers.i18n import Parser -from .auth.oauth import OAuthCredentials, RefreshingToken, OAuthToken +from .auth.oauth import OAuthCredentials, OAuthToken, RefreshingToken from .auth.oauth.base import Token from .auth.types import AuthType -class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, - UploadsMixin): +class YTMusic( + BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, UploadsMixin +): """ Allows automated interactions with YouTube Music by emulating the YouTube web client's requests. Permits both authenticated and non-authenticated requests. Authentication header data must be provided on initialization. """ - def __init__(self, - auth: Optional[str | Dict] = None, - user: str = None, - requests_session=True, - proxies: Dict = None, - language: str = 'en', - location: str = '', - oauth_credentials: Optional[OAuthCredentials] = None): + def __init__( + self, + auth: Optional[str | Dict] = None, + user: str = None, + requests_session=True, + proxies: Dict = None, + language: str = "en", + location: str = "", + oauth_credentials: Optional[OAuthCredentials] = None, + ): """ Create a new instance to interact with YouTube Music. @@ -101,9 +105,11 @@ def __init__(self, # see google cookie docs: https://policies.google.com/technologies/cookies # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 - self.cookies = {'SOCS': 'CAI'} + self.cookies = {"SOCS": "CAI"} if self.auth is not None: - self.oauth_credentials = oauth_credentials if oauth_credentials is not None else OAuthCredentials() + self.oauth_credentials = ( + oauth_credentials if oauth_credentials is not None else OAuthCredentials() + ) auth_filepath = None if isinstance(self.auth, str): if os.path.isfile(auth): @@ -128,31 +134,32 @@ def __init__(self, if location: if location not in SUPPORTED_LOCATIONS: raise Exception("Location not supported. Check the FAQ for supported locations.") - self.context['context']['client']['gl'] = location + self.context["context"]["client"]["gl"] = location if language not in SUPPORTED_LANGUAGES: - raise Exception("Language not supported. Supported languages are " - + (', '.join(SUPPORTED_LANGUAGES)) + ".") - self.context['context']['client']['hl'] = language + raise Exception( + "Language not supported. Supported languages are " + (", ".join(SUPPORTED_LANGUAGES)) + "." + ) + self.context["context"]["client"]["hl"] = language self.language = language try: locale.setlocale(locale.LC_ALL, self.language) except locale.Error: with suppress(locale.Error): - locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + locale.setlocale(locale.LC_ALL, "en_US.UTF-8") - locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' - self.lang = gettext.translation('base', localedir=locale_dir, languages=[language]) + locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + "locales" + self.lang = gettext.translation("base", localedir=locale_dir, languages=[language]) self.parser = Parser(self.lang) if user: - self.context['context']['user']['onBehalfOfUser'] = user + self.context["context"]["user"]["onBehalfOfUser"] = user auth_headers = self._input_dict.get("authorization") if auth_headers: if "SAPISIDHASH" in auth_headers: self.auth_type = AuthType.BROWSER - elif auth_headers.startswith('Bearer'): + elif auth_headers.startswith("Bearer"): self.auth_type = AuthType.OAUTH_CUSTOM_FULL # sapsid, origin, and params all set once during init @@ -160,9 +167,9 @@ def __init__(self, if self.auth_type == AuthType.BROWSER: self.params += YTM_PARAMS_KEY try: - cookie = self.base_headers.get('cookie') + cookie = self.base_headers.get("cookie") self.sapisid = sapisid_from_cookie(cookie) - self.origin = self.base_headers.get('origin', self.base_headers.get('x-origin')) + self.origin = self.base_headers.get("origin", self.base_headers.get("x-origin")) except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") @@ -178,7 +185,7 @@ def base_headers(self): "accept-encoding": "gzip, deflate", "content-type": "application/json", "content-encoding": "gzip", - "origin": YTM_DOMAIN + "origin": YTM_DOMAIN, } return self._base_headers @@ -191,11 +198,11 @@ def headers(self): # keys updated each use, custom oauth implementations left untouched if self.auth_type == AuthType.BROWSER: - self._headers["authorization"] = get_authorization(self.sapisid + ' ' + self.origin) + self._headers["authorization"] = get_authorization(self.sapisid + " " + self.origin) elif self.auth_type in AuthType.oauth_types(): - self._headers['authorization'] = self._token.as_auth() - self._headers['X-Goog-Request-Time'] = str(int(time.time())) + self._headers["authorization"] = self._token.as_auth() + self._headers["X-Goog-Request-Time"] = str(int(time.time())) return self._headers @@ -203,19 +210,20 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - body.update(self.context) # only required for post requests (?) - if 'X-Goog-Visitor-Id' not in self.headers: + if "X-Goog-Visitor-Id" not in self.headers: self._headers.update(get_visitor_id(self._send_get_request)) - response = self._session.post(YTM_BASE_API + endpoint + self.params + additionalParams, - json=body, - headers=self.headers, - proxies=self.proxies, - cookies=self.cookies) + response = self._session.post( + YTM_BASE_API + endpoint + self.params + additionalParams, + json=body, + headers=self.headers, + proxies=self.proxies, + cookies=self.cookies, + ) response_text = json.loads(response.text) if response.status_code >= 400: - message = "Server returned HTTP " + str( - response.status_code) + ": " + response.reason + ".\n" - error = response_text.get('error', {}).get('message') + message = "Server returned HTTP " + str(response.status_code) + ": " + response.reason + ".\n" + error = response_text.get("error", {}).get("message") raise Exception(message + error) return response_text @@ -226,7 +234,8 @@ def _send_get_request(self, url: str, params: Dict = None): # handle first-use x-goog-visitor-id fetching headers=self.headers if self._headers else self.base_headers, proxies=self.proxies, - cookies=self.cookies) + cookies=self.cookies, + ) return response def _check_auth(self):