From 0ed2c266d89673c94be68a85f9093e308a59826f Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Thu, 23 Apr 2020 20:17:45 -0600 Subject: [PATCH] Run black on entire project --- .editorconfig | 23 + .gitlab-ci.yml | 1 + .vim/coc-settings.json | 2 +- CONTRIBUTING.rst | 39 +- Pipfile | 11 +- Pipfile.lock | 219 +++++--- cicd/custom_style_check.py | 10 +- docs/conf.py | 43 +- setup.cfg | 4 +- setup.py | 91 ++- sublime/__main__.py | 52 +- sublime/adapters/__init__.py | 17 +- sublime/adapters/adapter_base.py | 221 ++++---- sublime/adapters/adapter_manager.py | 124 +++-- sublime/adapters/api_objects.py | 8 +- sublime/adapters/filesystem/__init__.py | 2 +- sublime/adapters/filesystem/adapter.py | 53 +- sublime/adapters/filesystem/models.py | 4 +- .../adapters/filesystem/sqlite_extensions.py | 40 +- sublime/adapters/subsonic/__init__.py | 2 +- sublime/adapters/subsonic/adapter.py | 104 ++-- sublime/adapters/subsonic/api_objects.py | 19 +- sublime/app.py | 518 +++++++++--------- sublime/cache_manager.py | 436 +++++++-------- sublime/config.py | 55 +- sublime/dbus/__init__.py | 4 +- sublime/dbus/manager.py | 313 +++++------ sublime/from_json.py | 30 +- sublime/players.py | 149 ++--- sublime/server/__init__.py | 2 +- sublime/server/api_object.py | 12 +- sublime/server/api_objects.py | 24 +- sublime/server/server.py | 511 ++++++++--------- sublime/ui/albums.py | 247 ++++----- sublime/ui/artists.py | 200 +++---- sublime/ui/browse.py | 143 ++--- sublime/ui/common/__init__.py | 12 +- sublime/ui/common/album_with_songs.py | 143 +++-- sublime/ui/common/edit_form_dialog.py | 46 +- sublime/ui/common/icon_button.py | 13 +- sublime/ui/common/song_list_column.py | 3 +- sublime/ui/common/spinner_image.py | 10 +- sublime/ui/configure_servers.py | 171 +++--- sublime/ui/main.py | 195 +++---- sublime/ui/player_controls.py | 372 ++++++------- sublime/ui/playlists.py | 341 +++++------- sublime/ui/settings.py | 34 +- sublime/ui/state.py | 42 +- sublime/ui/util.py | 135 +++-- .../adapter_tests/filesystem_adapter_tests.py | 151 +++-- tests/adapter_tests/subsonic_adapter_tests.py | 97 ++-- tests/config_test.py | 22 +- 52 files changed, 2603 insertions(+), 2917 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4179ddc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.py] +max_line_length = 88 + +[Makefile] +indent_style = tab + +# Indentation override for all JSON/YAML files +[*.{json,yaml,yml}] +indent_style = space +indent_size = 2 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9a52bf1..ecafa92 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,6 +19,7 @@ lint: - ./cicd/install-project-deps.sh script: - pipenv run python setup.py check -mrs + - pipenv run black --check . - pipenv run flake8 - pipenv run mypy sublime tests/**/*.py - pipenv run cicd/custom_style_check.py diff --git a/.vim/coc-settings.json b/.vim/coc-settings.json index 3ec63a2..eb8897c 100644 --- a/.vim/coc-settings.json +++ b/.vim/coc-settings.json @@ -5,5 +5,5 @@ "python.linting.flake8Enabled": true, "python.linting.enabled": true, "python.linting.mypyEnabled": true, - "python.formatting.provider": "yapf" + "python.formatting.provider": "black" } diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 88513c1..d068060 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -78,12 +78,41 @@ Building the flatpak Code Style ---------- -* `PEP-8`_ is to be followed **strictly**. -* `mypy`_ is used for type checking. -* ``print`` statements are not to be used except for when you actually want to - print to the terminal (which should be rare). In all other cases, the more - powerful and useful ``logging`` library should be used. +This project follows `PEP-8`_ **strictly**. The *only* exception is maximum line +length, which is 88 for this project (in accordance with ``black``'s defaults). +Additionally, lines that contain a single string literal are allowed to extend +past that. +Additionally, this project uses ``black`` to enforce consistent, deterministic +code style. +Although you can technically do all of the formatting yourself, it is +recommended that you use the following tools (they are automatically installed +if you are using pipenv). The CI process uses these to check all commits, so you +will probably want these so you don't have to wait for results of the build +before knowing if your code is the correct style. + +* `flake8`_ is used for linting. The following additional plugins are also used: + + * ``flake8-annotations``: enforce type annotations on function definitions. + * ``flake8-comprehensions``: enforce usage of comprehensions wherever + possible. + * ``flake8-importorder`` (with the ``edited`` import style): enforce ordering + of import statements. + * ``flake8-pep3101``: no ``%`` string formatting. + * ``flake8-print`` no print statements. Use the more powerful and useful + ``logging`` library instead. In the rare case that you actually want to + print to the terminal (the ``--version`` flag for example), then just + disable this check with a ``# noqa: T001`` comment. + +* `mypy`_ is used for type checking. All type errors must be resolved. +* `black`_ is used for auto-formatting. The CI process runs ``black --check`` to + make sure that you've run ``black`` on all files (or are just good at manually + formatting). +* ``TODO`` statements must include an associated issue number (in other words, + if you want to check in a change with outstanding TODOs, there must be an + issue associated with it to fix it). + +.. _black: https://github.com/psf/black .. _`PEP-8`: https://www.python.org/dev/peps/pep-0008/ .. _mypy: http://mypy-lang.org/ diff --git a/Pipfile b/Pipfile index 3017340..6320682 100644 --- a/Pipfile +++ b/Pipfile @@ -4,10 +4,12 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] -dataclasses-json = {git = "https://github.com/sumnerevans/dataclasses-json",ref = "cc2eaeb"} +black = "*" +dataclasses-json = {git = "https://github.com/lidatong/dataclasses-json",ref = "master"} docutils = "*" -flake8 = "*" +flake8 = {git = "https://gitlab.com/pycqa/flake8",ref = "master"} flake8-annotations = "*" +flake8-bugbear = "*" flake8-comprehensions = "*" flake8-import-order = "*" flake8-pep3101 = "*" @@ -15,6 +17,7 @@ flake8-print = "*" graphviz = "*" lxml = "*" mypy = "*" +pycodestyle = "==2.6.0a1" pytest = "*" pytest-cov = "*" rope = "*" @@ -22,10 +25,12 @@ rst2html5 = "*" sphinx = "*" sphinx-rtd-theme = "*" termcolor = "*" -yapf = "*" [packages] sublime-music = {editable = true,extras = ["keyring"],path = "."} [requires] python_version = "3.8" + +[pipenv] +allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock index bc04669..e58f5a4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1b3ed7bc26fc014d648a0fddf4cde814f5ea583a464bf457548326a67825601c" + "sha256": "dfed6ede3c95b6007e782cdce8117157f9d5e02aed39af78acf6055bb6dd9d75" }, "pipfile-spec": 6, "requires": { @@ -192,26 +192,24 @@ }, "protobuf": { "hashes": [ - "sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab", - "sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f", - "sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a", - "sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0", - "sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4", - "sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2", - "sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee", - "sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07", - "sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151", - "sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a", - "sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f", - "sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7", - "sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956", - "sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306", - "sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961", - "sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481", - "sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a", - "sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80" + "sha256:03b31ec00ad94d4947fd87f49b288e60f443370fd1927fae80411d2dd864fbb5", + "sha256:0b845c1fb8f36be203cd2ca9e405a22ee2cec2ed87d180b067d7c063f5701633", + "sha256:0d1fec40323c8e10812897c71453c33401f6ccc6ade98c5a3fef1f019de797e6", + "sha256:375ab5683efc946d1340dcf53dd422ccb55fbe88c0e16408182ca9a73248d91e", + "sha256:3c5a1a0acd42a3fa39ce0b1436cd7faaa1e77ecaac58cd87101f56b2fe99f628", + "sha256:3f01f6a479aff857615f2decaba773470816727fa6be6291866bd966d6ae3c61", + "sha256:4cae6edd604ddbbaadd90da13df06fdf399d3fa9f19950e78340ab69f59f103c", + "sha256:4edae95bff0e4a010059462b4a0116366863573c105ba689fc19ed9dae16888d", + "sha256:67412c3eb0299a2c908d86dea1ceab9e65558684abd2f53e9f85ae28f03ba7b3", + "sha256:8765978e2e553a7a9a7d4aa64b957f111a0358d85d799e378dc458b653ea2de5", + "sha256:8bba760eb61044120cb91552f55c4b2fa3a80c8639fae8583b53b3e3a7e8da56", + "sha256:996542402404aa8577defcdebbf9a0780bd96c7af2f562eefd4542716ca369a1", + "sha256:a4cb8388c3f75d36ac51667e678f4c3096f672229d3e68d1db18675d4f59e5a2", + "sha256:b8404d27772f130299185e20e4379a2b3450c7d1197396131cc2ec4626db75cb", + "sha256:b9c9692d2842ff7846b0c2574be8e921247b7c377f4c03cd6370aef077fb652c", + "sha256:caed753a89e5ffc2fbbf624926eacc3924c884181374bd3ddf54ca0a2903eb11" ], - "version": "==3.11.3" + "version": "==3.12.0rc1" }, "pycairo": { "hashes": [ @@ -354,6 +352,13 @@ ], "version": "==0.7.12" }, + "appdirs": { + "hashes": [ + "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", + "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" + ], + "version": "==1.4.3" + }, "attrs": { "hashes": [ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", @@ -368,6 +373,14 @@ ], "version": "==2.8.0" }, + "black": { + "hashes": [ + "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", + "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" + ], + "index": "pypi", + "version": "==19.10b0" + }, "certifi": { "hashes": [ "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", @@ -382,6 +395,13 @@ ], "version": "==3.0.4" }, + "click": { + "hashes": [ + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "version": "==7.1.2" + }, "coverage": { "hashes": [ "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", @@ -433,20 +453,9 @@ "index": "pypi", "version": "==0.16" }, - "entrypoints": { - "hashes": [ - "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", - "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" - ], - "version": "==0.3" - }, "flake8": { - "hashes": [ - "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", - "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" - ], - "index": "pypi", - "version": "==3.7.9" + "git": "https://gitlab.com/pycqa/flake8", + "ref": "0c3b8045a7b51aec7abf19dea94d5292cebeeea0" }, "flake8-annotations": { "hashes": [ @@ -456,6 +465,14 @@ "index": "pypi", "version": "==2.1.0" }, + "flake8-bugbear": { + "hashes": [ + "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63", + "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162" + ], + "index": "pypi", + "version": "==20.1.4" + }, "flake8-comprehensions": { "hashes": [ "sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2", @@ -518,10 +535,10 @@ }, "jinja2": { "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + "sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484", + "sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668" ], - "version": "==2.11.2" + "version": "==3.0.0a1" }, "lxml": { "hashes": [ @@ -558,41 +575,30 @@ }, "markupsafe": { "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + "sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f", + "sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db", + "sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7", + "sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a", + "sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054", + "sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977", + "sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0", + "sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4", + "sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba", + "sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761", + "sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3", + "sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0", + "sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8", + "sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d", + "sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1", + "sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45", + "sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e", + "sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1", + "sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428", + "sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b", + "sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6", + "sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f" ], - "version": "==1.1.1" + "version": "==2.0.0a1" }, "mccabe": { "hashes": [ @@ -642,6 +648,13 @@ ], "version": "==20.3" }, + "pathspec": { + "hashes": [ + "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", + "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" + ], + "version": "==0.8.0" + }, "pluggy": { "hashes": [ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", @@ -658,17 +671,18 @@ }, "pycodestyle": { "hashes": [ - "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", - "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" + "sha256:933bfe8d45355fbb35f9017d81fc51df8cb7ce58b82aca2568b870bf7bea1611", + "sha256:c1362bf675a7c0171fa5f795917c570c2e405a97e5dc473b51f3656075d73acc" ], - "version": "==2.5.0" + "index": "pypi", + "version": "==2.6.0a1" }, "pyflakes": { "hashes": [ - "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", - "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", + "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], - "version": "==2.1.1" + "version": "==2.2.0" }, "pygments": { "hashes": [ @@ -679,10 +693,10 @@ }, "pyparsing": { "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + "sha256:67199f0c41a9c702154efb0e7a8cc08accf830eb003b4d9fa42c4059002e2492", + "sha256:700d17888d441604b0bd51535908dcb297561b040819cccde647a92439db5a2a" ], - "version": "==2.4.7" + "version": "==3.0.0a1" }, "pytest": { "hashes": [ @@ -707,6 +721,32 @@ ], "version": "==2020.1" }, + "regex": { + "hashes": [ + "sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b", + "sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8", + "sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3", + "sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e", + "sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683", + "sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1", + "sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142", + "sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3", + "sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468", + "sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e", + "sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3", + "sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a", + "sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f", + "sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6", + "sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156", + "sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b", + "sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db", + "sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd", + "sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a", + "sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948", + "sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89" + ], + "version": "==2020.4.4" + }, "requests": { "hashes": [ "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", @@ -752,11 +792,11 @@ }, "sphinx-rtd-theme": { "hashes": [ - "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", - "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" + "sha256:1ba9bbc8898ed8531ac8d140b4ff286d57010fb878303b2efae3303726ec821b", + "sha256:a18194ae459f6a59b0d56e4a8b4c576c0125fb9a12f2211e652b4a8133092e14" ], "index": "pypi", - "version": "==0.4.3" + "version": "==0.5.0rc1" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -807,6 +847,13 @@ "index": "pypi", "version": "==1.1.0" }, + "toml": { + "hashes": [ + "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", + "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" + ], + "version": "==0.10.0" + }, "typed-ast": { "hashes": [ "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", @@ -854,14 +901,6 @@ "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" ], "version": "==0.1.9" - }, - "yapf": { - "hashes": [ - "sha256:3000abee4c28daebad55da6c85f3cd07b8062ce48e2e9943c8da1b9667d48427", - "sha256:3abf61ba67cf603069710d30acbc88cfe565d907e16ad81429ae90ce9651e0c9" - ], - "index": "pypi", - "version": "==0.30.0" } } } diff --git a/cicd/custom_style_check.py b/cicd/custom_style_check.py index 6b01f68..2f5ed37 100755 --- a/cicd/custom_style_check.py +++ b/cicd/custom_style_check.py @@ -6,18 +6,18 @@ from pathlib import Path from termcolor import cprint -todo_re = re.compile(r'#\s*TODO:?\s*') -accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)') +todo_re = re.compile(r"#\s*TODO:?\s*") +accounted_for_todo = re.compile(r"#\s*TODO:?\s*\((#\d+)\)") def check_file(path: Path) -> bool: - print(f'Checking {path.absolute()}...') # noqa: T001 + print(f"Checking {path.absolute()}...") # noqa: T001 file = path.open() valid = True for i, line in enumerate(file, start=1): if todo_re.search(line) and not accounted_for_todo.search(line): - cprint(f'{i}: {line}', 'red', end='', attrs=['bold']) + cprint(f"{i}: {line}", "red", end="", attrs=["bold"]) valid = False file.close() @@ -25,7 +25,7 @@ def check_file(path: Path) -> bool: valid = True -for path in Path('sublime').glob('**/*.py'): +for path in Path("sublime").glob("**/*.py"): valid &= check_file(path) print() # noqa: T001 diff --git a/docs/conf.py b/docs/conf.py index cc0c6dc..7f8ce9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,14 +18,15 @@ import datetime -project = 'Sublime Music' -copyright = f'{datetime.datetime.today().year}, Sumner Evans' -author = 'Sumner Evans' -gitlab = 'https://gitlab.com/sumner/sublime-music/' +project = "Sublime Music" +copyright = f"{datetime.datetime.today().year}, Sumner Evans" +author = "Sumner Evans" +gitlab = "https://gitlab.com/sumner/sublime-music/" # Get the version from the package. import sublime -release = f'v{sublime.__version__}' + +release = f"v{sublime.__version__}" # -- General configuration --------------------------------------------------- @@ -33,37 +34,37 @@ release = f'v{sublime.__version__}' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.autosectionlabel', - 'sphinx.ext.intersphinx', - 'sphinx.ext.mathjax', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.autosectionlabel", + "sphinx.ext.intersphinx", + "sphinx.ext.mathjax", + "sphinx.ext.viewcode", ] autodoc_default_options = { - 'members': True, - 'undoc-members': True, - 'show-inheritance': True, - 'special-members': '__init__', + "members": True, + "undoc-members": True, + "show-inheritance": True, + "special-members": "__init__", } autosectionlabel_prefix_document = True intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None), + "python": ("https://docs.python.org/3", None), } # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The master toctree document. -master_doc = 'index' +master_doc = "index" # 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 = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" rst_epilog = f""" ------------------------------------------------------------------------------- @@ -81,9 +82,9 @@ rst_epilog = f""" # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] diff --git a/setup.cfg b/setup.cfg index 2b13bc3..e4fd213 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,8 @@ [flake8] -ignore = E402, W503, ANN002, ANN003, ANN101, ANN102, ANN204 +select = C,E,F,W,B,B950 +ignore = E402, E501, W503, ANN002, ANN003, ANN101, ANN102, ANN204 exclude = .git,__pycache__,build,dist,flatpak +max-line-length = 80 suppress-none-returning = True suppress-dummy-args = True application-import-names = sublime diff --git a/setup.py b/setup.py index 72cd6dc..f37d7e9 100644 --- a/setup.py +++ b/setup.py @@ -5,83 +5,72 @@ from setuptools import find_packages, setup here = os.path.abspath(os.path.dirname(__file__)) -with codecs.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: +with codecs.open(os.path.join(here, "README.rst"), encoding="utf-8") as f: long_description = f.read() # Find the version -with codecs.open(os.path.join(here, 'sublime/__init__.py'), - encoding='utf-8') as f: +with codecs.open(os.path.join(here, "sublime/__init__.py"), encoding="utf-8") as f: for line in f: - if line.startswith('__version__'): + if line.startswith("__version__"): version = eval(line.split()[-1]) break setup( - name='sublime-music', + name="sublime-music", version=version, - url='https://gitlab.com/sumner/sublime-music', - description='A native GTK *sonic client.', + url="https://gitlab.com/sumner/sublime-music", + description="A native GTK *sonic client.", long_description=long_description, - author='Sumner Evans', - author_email='inquiries@sumnerevans.com', - license='GPL3', + author="Sumner Evans", + author_email="inquiries@sumnerevans.com", + license="GPL3", classifiers=[ # 3 - Alpha # 4 - Beta # 5 - Production/Stable - 'Development Status :: 3 - Alpha', - + "Development Status :: 3 - Alpha", # Indicate who your project is intended for - 'Intended Audience :: End Users/Desktop', - 'Operating System :: POSIX', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - + "Intended Audience :: End Users/Desktop", + "Operating System :: POSIX", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", # Specify the Python versions you support here. In particular, ensure # that you indicate whether you support Python 2, Python 3 or both. - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", ], - keywords='airsonic subsonic libresonic gonic music', - packages=find_packages(exclude=['tests']), + keywords="airsonic subsonic libresonic gonic music", + packages=find_packages(exclude=["tests"]), package_data={ - 'sublime': [ - 'ui/app_styles.css', - 'ui/images/play-queue-play.png', - 'ui/images/default-album-art.png', - 'dbus/mpris_specs/org.mpris.MediaPlayer2.xml', - 'dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml', - 'dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml', - 'dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml', + "sublime": [ + "ui/app_styles.css", + "ui/images/play-queue-play.png", + "ui/images/default-album-art.png", + "dbus/mpris_specs/org.mpris.MediaPlayer2.xml", + "dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml", + "dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml", + "dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml", ] }, install_requires=[ - 'bottle', - 'dataclasses-json @ git+https://github.com/sumnerevans/dataclasses-json@cc2eaeb#egg=dataclasses-json', # noqa: E501 - 'deepdiff', - 'Deprecated', - 'fuzzywuzzy', + "bottle", + "dataclasses-json @ git+https://github.com/sumnerevans/dataclasses-json@cc2eaeb#egg=dataclasses-json", # noqa: E501 + "deepdiff", + "Deprecated", + "fuzzywuzzy", 'osxmmkeys ; sys_platform=="darwin"', - 'peewee', - 'pychromecast', - 'PyGObject', - 'python-dateutil', - 'python-Levenshtein', - 'python-mpv', - 'pyyaml', - 'requests', + "peewee", + "pychromecast", + "PyGObject", + "python-dateutil", + "python-Levenshtein", + "python-mpv", + "pyyaml", + "requests", ], - extras_require={ - "keyring": ["keyring"], - }, - - + extras_require={"keyring": ["keyring"]}, # To provide executable scripts, use entry points in preference to the # "scripts" keyword. Entry points provide cross-platform support and # allow pip to create the appropriate form of executable for the target # platform. - entry_points={ - 'console_scripts': [ - 'sublime-music=sublime.__main__:main', - ], - }, + entry_points={"console_scripts": ["sublime-music=sublime.__main__:main"]}, ) diff --git a/sublime/__main__.py b/sublime/__main__.py index a563535..2b21cae 100644 --- a/sublime/__main__.py +++ b/sublime/__main__.py @@ -5,7 +5,8 @@ import os from pathlib import Path import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa: F401 import sublime @@ -13,55 +14,50 @@ from sublime.app import SublimeMusicApp def main(): - parser = argparse.ArgumentParser(description='Sublime Music') + parser = argparse.ArgumentParser(description="Sublime Music") parser.add_argument( - '-v', - '--version', - help='show version and exit', - action='store_true', + "-v", "--version", help="show version and exit", action="store_true" + ) + parser.add_argument("-l", "--logfile", help="the filename to send logs to") + parser.add_argument( + "-m", "--loglevel", help="the minium level of logging to do", default="WARNING", ) parser.add_argument( - '-l', - '--logfile', - help='the filename to send logs to', - ) - parser.add_argument( - '-m', - '--loglevel', - help='the minium level of logging to do', - default='WARNING', - ) - parser.add_argument( - '-c', - '--config', - help='specify a configuration file. Defaults to ' - '~/.config/sublime-music/config.json', + "-c", + "--config", + help="specify a configuration file. Defaults to " + "~/.config/sublime-music/config.json", ) args, unknown_args = parser.parse_known_args() if args.version: - print(f'Sublime Music v{sublime.__version__}') # noqa: T001 + print(f"Sublime Music v{sublime.__version__}") # noqa: T001 return min_log_level = getattr(logging, args.loglevel.upper(), None) if not isinstance(min_log_level, int): - logging.error(f'Invalid log level: {args.loglevel.upper()}.') + logging.error(f"Invalid log level: {args.loglevel.upper()}.") min_log_level = logging.WARNING logging.basicConfig( filename=args.logfile, level=min_log_level, - format='%(asctime)s:%(levelname)s:%(name)s:%(module)s:%(message)s', + format="%(asctime)s:%(levelname)s:%(name)s:%(module)s:%(message)s", ) # Config File config_file = args.config if not config_file: # Default to ~/.config/sublime-music. - config_file = Path( - os.environ.get('XDG_CONFIG_HOME') or os.environ.get('APPDATA') - or os.path.join('~/.config')).expanduser().joinpath( - 'sublime-music', 'config.yaml') + config_file = ( + Path( + os.environ.get("XDG_CONFIG_HOME") + or os.environ.get("APPDATA") + or os.path.join("~/.config") + ) + .expanduser() + .joinpath("sublime-music", "config.yaml") + ) app = SublimeMusicApp(Path(config_file)) app.run(unknown_args) diff --git a/sublime/adapters/__init__.py b/sublime/adapters/__init__.py index dfb3b97..97a9da0 100644 --- a/sublime/adapters/__init__.py +++ b/sublime/adapters/__init__.py @@ -1,15 +1,10 @@ -from .adapter_base import ( - Adapter, - CacheMissError, - CachingAdapter, - ConfigParamDescriptor, -) +from .adapter_base import Adapter, CacheMissError, CachingAdapter, ConfigParamDescriptor from .adapter_manager import AdapterManager __all__ = ( - 'Adapter', - 'AdapterManager', - 'CacheMissError', - 'CachingAdapter', - 'ConfigParamDescriptor', + "Adapter", + "AdapterManager", + "CacheMissError", + "CachingAdapter", + "ConfigParamDescriptor", ) diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 7830e92..81a847b 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -21,22 +21,21 @@ from .api_objects import ( class CacheMissError(Exception): """ - This exception should be thrown by caching adapters when the request data - is not available or is invalid. If some of the data is available, but not - all of it, the ``partial_data`` parameter should be set with the partial - data. If the ground truth adapter can't service the request, or errors for - some reason, the UI will try to populate itself with the partial data - returned in this exception (with the necessary error text to inform the - user that retrieval from the ground truth adapter failed). + This exception should be thrown by caching adapters when the request data is not + available or is invalid. If some of the data is available, but not all of it, the + ``partial_data`` parameter should be set with the partial data. If the ground truth + adapter can't service the request, or errors for some reason, the UI will try to + populate itself with the partial data returned in this exception (with the necessary + error text to inform the user that retrieval from the ground truth adapter failed). """ + def __init__(self, *args, partial_data: Any = None): """ Create a :class:`CacheMissError` exception. - :param args: arguments to pass to the :class:`BaseException` base - class. - :param partial_data: the actual partial data for the UI to use in case - of ground truth adapter failure. + :param args: arguments to pass to the :class:`BaseException` base class. + :param partial_data: the actual partial data for the UI to use in case of ground + truth adapter failure. """ self.partial_data = partial_data super().__init__(*args) @@ -46,31 +45,30 @@ class CacheMissError(Exception): class ConfigParamDescriptor: """ Describes a parameter that can be used to configure an adapter. The - :class:`description`, :class:`required` and :class:`default:` should be - self-evident as to what they do. + :class:`description`, :class:`required` and :class:`default:` should be self-evident + as to what they do. The :class:`type` must be one of the following: - * The literal type ``str``: corresponds to a freeform text entry field in - the UI. + * The literal type ``str``: corresponds to a freeform text entry field in the UI. * The literal type ``bool``: corresponds to a checkbox in the UI. * The literal type ``int``: corresponds to a numeric input in the UI. - * The literal string ``"password"``: corresponds to a password entry field - in the UI. + * The literal string ``"password"``: corresponds to a password entry field in the + UI. * The literal string ``"option"``: corresponds to dropdown in the UI. - The :class:`numeric_bounds` parameter only has an effect if the - :class:`type` is `int`. It specifies the min and max values that the UI - control can have. + The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is + `int`. It specifies the min and max values that the UI control can have. - The :class:`numeric_step` parameter only has an effect if the :class:`type` - is `int`. It specifies the step that will be taken using the "+" and "-" - buttons on the UI control (if supported). + The :class:`numeric_step` parameter only has an effect if the :class:`type` is + `int`. It specifies the step that will be taken using the "+" and "-" buttons on the + UI control (if supported). The :class:`options` parameter only has an effect if the :class:`type` is - ``"option"``. It specifies the list of options that will be available in - the dropdown in the UI. + ``"option"``. It specifies the list of options that will be available in the + dropdown in the UI. """ + type: Union[Type, str] description: str required: bool = True @@ -84,10 +82,11 @@ class Adapter(abc.ABC): """ Defines the interface for a Sublime Music Adapter. - All functions that actually retrieve data have a corresponding: - ``can_``-prefixed property (which can be dynamic) which specifies whether - or not the adapter supports that operation at the moment. + All functions that actually retrieve data have a corresponding: ``can_``-prefixed + property (which can be dynamic) which specifies whether or not the adapter supports + that operation at the moment. """ + # Configuration and Initialization Properties # These properties determine how the adapter can be configured and how to # initialize the adapter given those configuration values. @@ -98,58 +97,53 @@ class Adapter(abc.ABC): """ Specifies the settings which can be configured for the adapter. - :returns: An dictionary where the keys are the name of the - configuration paramter and the values are the - :class:`ConfigParamDescriptor` object corresponding to that - configuration parameter. The order of the keys in the dictionary - correspond to the order that the configuration parameters will be + :returns: An dictionary where the keys are the name of the configuration + paramter and the values are the :class:`ConfigParamDescriptor` object + corresponding to that configuration parameter. The order of the keys in the + dictionary correspond to the order that the configuration parameters will be shown in the UI. """ @staticmethod @abc.abstractmethod - def verify_configuration( - config: Dict[str, Any]) -> Dict[str, Optional[str]]: + def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]: """ Specifies a function for verifying whether or not the config is valid. - :param config: The adapter configuration. The keys of are the - configuration parameter names as defined by the return value of the - :class:`get_config_parameters` function. The values are the actual - value of the configuration parameter. It is guaranteed that all - configuration parameters that are marked as required will have a - value in ``config``. + :param config: The adapter configuration. The keys of are the configuration + parameter names as defined by the return value of the + :class:`get_config_parameters` function. The values are the actual value of + the configuration parameter. It is guaranteed that all configuration + parameters that are marked as required will have a value in ``config``. - :returns: A dictionary containing varification errors. The keys of the - returned dictionary should be the same as the passed in via the - ``config`` parameter. The values should be strings describing why - the corresponding value in the ``config`` dictionary is invalid. + :returns: A dictionary containing varification errors. The keys of the returned + dictionary should be the same as the passed in via the ``config`` parameter. + The values should be strings describing why the corresponding value in the + ``config`` dictionary is invalid. - Not all keys need be returned (for example, if there's no error for - a given configuration parameter), and returning `None` indicates no - error. + Not all keys need be returned (for example, if there's no error for a given + configuration parameter), and returning `None` indicates no error. """ @abc.abstractmethod def __init__(self, config: dict, data_directory: Path): """ - This function should be overridden by inheritors of - :class:`Adapter` and should be used to do whatever setup is - required for the adapter. + This function should be overridden by inheritors of :class:`Adapter` and should + be used to do whatever setup is required for the adapter. - :param config: The adapter configuration. The keys of are the - configuration parameter names as defined by the return value of the - :class:`get_config_parameters` function. The values are the actual - value of the configuration parameter. - :param data_directory: the directory where the adapter can store data. - This directory is guaranteed to exist. + :param config: The adapter configuration. The keys of are the configuration + parameter names as defined by the return value of the + :class:`get_config_parameters` function. The values are the actual value of + the configuration parameter. + :param data_directory: the directory where the adapter can store data. This + directory is guaranteed to exist. """ def shutdown(self): """ - This function is called when the app is being closed or the server is - changing. This should be used to clean up anything that is necessary - such as writing a cache to disk, disconnecting from a server, etc. + This function is called when the app is being closed or the server is changing. + This should be used to clean up anything that is necessary such as writing a + cache to disk, disconnecting from a server, etc. """ # Usage Properties @@ -159,11 +153,11 @@ class Adapter(abc.ABC): @property def can_be_cached(self) -> bool: """ - Specifies whether or not this adapter can be used as the ground-truth - adapter behind a caching adapter. + Specifies whether or not this adapter can be used as the ground-truth adapter + behind a caching adapter. - The default is ``True``, since most adapters will want to take - advantage of the built-in filesystem cache. + The default is ``True``, since most adapters will want to take advantage of the + built-in filesystem cache. """ return True @@ -175,13 +169,12 @@ class Adapter(abc.ABC): @abc.abstractmethod def can_service_requests(self) -> bool: """ - Specifies whether or not the adapter can currently service requests. If - this is ``False``, none of the other data retrieval functions are - expected to work. + Specifies whether or not the adapter can currently service requests. If this is + ``False``, none of the other data retrieval functions are expected to work. - For example, if your adapter requires access to an external service, - use this function to determine if it is currently possible to connect - to that external service. + For example, if your adapter requires access to an external service, use this + function to determine if it is currently possible to connect to that external + service. """ @property @@ -194,8 +187,7 @@ class Adapter(abc.ABC): @property def can_get_playlist_details(self) -> bool: """ - Whether :class:`get_playlist_details` can be called on the adapter - right now. + Whether :class:`get_playlist_details` can be called on the adapter right now. """ return False @@ -208,86 +200,77 @@ class Adapter(abc.ABC): # ========================================================================= def get_playlists(self) -> Sequence[Playlist]: """ - Gets a list of all of the :class:`sublime.adapter.api_objects.Playlist` - objects known to the adapter. + Gets a list of all of the :class:`sublime.adapter.api_objects.Playlist` objects + known to the adapter. """ - raise self._check_can_error('get_playlists') + raise self._check_can_error("get_playlists") - def get_playlist_details( - self, - playlist_id: str, - ) -> PlaylistDetails: + def get_playlist_details(self, playlist_id: str,) -> PlaylistDetails: """ - Gets the details about the given ``playlist_id``. If the playlist_id - does not exist, then this function should throw an exception. + Gets the details about the given ``playlist_id``. If the playlist_id does not + exist, then this function should throw an exception. :param playlist_id: The ID of the playlist to retrieve. """ - raise self._check_can_error('get_playlist_details') + raise self._check_can_error("get_playlist_details") @staticmethod def _check_can_error(method_name: str) -> NotImplementedError: return NotImplementedError( - f'Adapter.{method_name} called. ' - 'Did you forget to check that can_{method_name} is True?') + f"Adapter.{method_name} called. " + "Did you forget to check that can_{method_name} is True?" + ) class CachingAdapter(Adapter): """ Defines an adapter that can be used as a cache for another adapter. - A caching adapter sits "in front" of a non-caching adapter and the UI - will attempt to retrieve the data from the caching adapter before - retrieving it from the non-caching adapter. (The exception is when the - UI requests that the data come directly from the ground truth adapter, - in which case the cache will be bypassed.) + A caching adapter sits "in front" of a non-caching adapter and the UI will attempt + to retrieve the data from the caching adapter before retrieving it from the + non-caching adapter. (The exception is when the UI requests that the data come + directly from the ground truth adapter, in which case the cache will be bypassed.) - Caching adapters *must* be able to service requests instantly, or - nearly instantly (in most cases, this meanst the data must come - directly from the local filesystem). + Caching adapters *must* be able to service requests instantly, or nearly instantly + (in most cases, this meanst the data must come directly from the local filesystem). """ - @abc.abstractmethod - def __init__( - self, - config: dict, - data_directory: Path, - is_cache: bool = False, - ): - """ - This function should be overridden by inheritors of - :class:`CachingAdapter` and should be used to do whatever setup is - required for the adapter. - :param config: The adapter configuration. The keys of are the - configuration parameter names as defined by the return value of the - :class:`get_config_parameters` function. The values are the actual - value of the configuration parameter. - :param data_directory: the directory where the adapter can store data. - This directory is guaranteed to exist. + @abc.abstractmethod + def __init__(self, config: dict, data_directory: Path, is_cache: bool = False): + """ + This function should be overridden by inheritors of :class:`CachingAdapter` and + should be used to do whatever setup is required for the adapter. + + :param config: The adapter configuration. The keys of are the configuration + parameter names as defined by the return value of the + :class:`get_config_parameters` function. The values are the actual value of + the configuration parameter. + :param data_directory: the directory where the adapter can store data. This + directory is guaranteed to exist. :param is_cache: whether or not the adapter is being used as a cache. """ # Data Ingestion Methods # ========================================================================= class FunctionNames(Enum): - GET_PLAYLISTS = 'get_playlists' - GET_PLAYLIST_DETAILS = 'get_playlist_details' + GET_PLAYLISTS = "get_playlists" + GET_PLAYLIST_DETAILS = "get_playlist_details" @abc.abstractmethod def ingest_new_data( self, - function: 'CachingAdapter.FunctionNames', + function: "CachingAdapter.FunctionNames", params: Tuple[Any, ...], data: Any, ): """ - This function will be called after the fallback, ground-truth adapter - returns new data. This normally will happen if this adapter has a cache - miss or if the UI forces retrieval from the ground-truth adapter. + This function will be called after the fallback, ground-truth adapter returns + new data. This normally will happen if this adapter has a cache miss or if the + UI forces retrieval from the ground-truth adapter. - :param function_name: the name of the function that was called on the - ground truth adapter. - :param params: the parameters that were passed to the function on the - ground truth adapter. + :param function_name: the name of the function that was called on the ground + truth adapter. + :param params: the parameters that were passed to the function on the ground + truth adapter. :param data: the data that was returned by the ground truth adapter. """ diff --git a/sublime/adapters/adapter_manager.py b/sublime/adapters/adapter_manager.py index 53bcb12..6bfb5a0 100644 --- a/sublime/adapters/adapter_manager.py +++ b/sublime/adapters/adapter_manager.py @@ -21,15 +21,16 @@ from .api_objects import Playlist, PlaylistDetails from .filesystem import FilesystemAdapter from .subsonic import SubsonicAdapter -T = TypeVar('T') +T = TypeVar("T") class Result(Generic[T]): """ - A result from a :class:`AdapterManager` function. This is effectively a - wrapper around a :class:`concurrent.futures.Future`, but it can also - resolve immediately if the data already exists. + A result from a :class:`AdapterManager` function. This is effectively a wrapper + around a :class:`concurrent.futures.Future`, but it can also resolve immediately if + the data already exists. """ + _data: Optional[T] = None _future: Optional[Future] = None on_cancel: Optional[Callable[[], None]] = None @@ -51,8 +52,7 @@ class Result(Generic[T]): if self._future is not None: return self._future.result() - raise Exception( - 'AdapterManager.Result had neither _data nor _future member!') + raise Exception("AdapterManager.Result had neither _data nor _future member!") def add_done_callback(self, fn: Callable, *args): if self._future is not None: @@ -91,28 +91,28 @@ class AdapterManager: @staticmethod def register_adapter(adapter_class: Type): if not issubclass(adapter_class, Adapter): - raise TypeError( - 'Attempting to register a class that is not an adapter.') + raise TypeError("Attempting to register a class that is not an adapter.") AdapterManager.available_adapters.add(adapter_class) def __init__(self): """ - This should not ever be called. You should only ever use the static - methods on this class. + This should not ever be called. You should only ever use the static methods on + this class. """ raise Exception( - "Cannot instantiate AdapterManager. Only use the static methods " - "on the class.") + "Do not instantiate the AdapterManager. " + "Only use the static methods on the class." + ) @staticmethod def shutdown(): - logging.info('AdapterManager shutdown start') + logging.info("AdapterManager shutdown start") AdapterManager.is_shutting_down = True AdapterManager.executor.shutdown() if AdapterManager._instance: AdapterManager._instance.shutdown() - logging.info('CacheManager shutdown complete') + logging.info("CacheManager shutdown complete") @staticmethod def reset(config: AppConfiguration): @@ -124,8 +124,8 @@ class AdapterManager: # to create, etc. assert config.server is not None source_data_dir = Path(config.cache_location, config.server.strhash()) - source_data_dir.joinpath('g').mkdir(parents=True, exist_ok=True) - source_data_dir.joinpath('c').mkdir(parents=True, exist_ok=True) + source_data_dir.joinpath("g").mkdir(parents=True, exist_ok=True) + source_data_dir.joinpath("c").mkdir(parents=True, exist_ok=True) ground_truth_adapter_type = SubsonicAdapter ground_truth_adapter = ground_truth_adapter_type( @@ -133,7 +133,7 @@ class AdapterManager: key: getattr(config.server, key) for key in ground_truth_adapter_type.get_config_parameters() }, - source_data_dir.joinpath('g'), + source_data_dir.joinpath("g"), ) caching_adapter_type = FilesystemAdapter @@ -144,23 +144,22 @@ class AdapterManager: key: getattr(config.server, key) for key in caching_adapter_type.get_config_parameters() }, - source_data_dir.joinpath('c'), + source_data_dir.joinpath("c"), is_cache=True, ) AdapterManager._instance = AdapterManager._AdapterManagerInternal( - ground_truth_adapter, - caching_adapter=caching_adapter, + ground_truth_adapter, caching_adapter=caching_adapter, ) @staticmethod def can_get_playlists() -> bool: # It only matters that the ground truth one can service the request. return ( - AdapterManager._instance is not None and - AdapterManager._instance.ground_truth_adapter.can_service_requests - and - AdapterManager._instance.ground_truth_adapter.can_get_playlists) + AdapterManager._instance is not None + and AdapterManager._instance.ground_truth_adapter.can_service_requests + and AdapterManager._instance.ground_truth_adapter.can_get_playlists + ) @staticmethod def get_playlists( @@ -168,32 +167,31 @@ class AdapterManager: force: bool = False, # TODO: rename to use_ground_truth_adapter? ) -> Result[Sequence[Playlist]]: assert AdapterManager._instance - if (not force and AdapterManager._instance.caching_adapter and - AdapterManager._instance.caching_adapter.can_service_requests - and - AdapterManager._instance.caching_adapter.can_get_playlists): + if ( + not force + and AdapterManager._instance.caching_adapter + and AdapterManager._instance.caching_adapter.can_service_requests + and AdapterManager._instance.caching_adapter.can_get_playlists + ): try: - return Result( - AdapterManager._instance.caching_adapter.get_playlists()) + return Result(AdapterManager._instance.caching_adapter.get_playlists()) except CacheMissError: logging.debug(f'Cache Miss on {"get_playlists"}.') except Exception: - logging.exception( - f'Error on {"get_playlists"} retrieving from cache.') + logging.exception(f'Error on {"get_playlists"} retrieving from cache.') - if (AdapterManager._instance.ground_truth_adapter - and not AdapterManager._instance.ground_truth_adapter - .can_service_requests and not AdapterManager._instance - .ground_truth_adapter.can_get_playlists): - raise Exception( - f'No adapters can service {"get_playlists"} at the moment.') + if ( + AdapterManager._instance.ground_truth_adapter + and not AdapterManager._instance.ground_truth_adapter.can_service_requests + and not AdapterManager._instance.ground_truth_adapter.can_get_playlists + ): + raise Exception(f'No adapters can service {"get_playlists"} at the moment.') def future_fn() -> Sequence[Playlist]: assert AdapterManager._instance if before_download: before_download() - return ( - AdapterManager._instance.ground_truth_adapter.get_playlists()) + return AdapterManager._instance.ground_truth_adapter.get_playlists() future: Result[Sequence[Playlist]] = Result(future_fn) @@ -203,9 +201,7 @@ class AdapterManager: assert AdapterManager._instance assert AdapterManager._instance.caching_adapter AdapterManager._instance.caching_adapter.ingest_new_data( - CachingAdapter.FunctionNames.GET_PLAYLISTS, - (), - f.result(), + CachingAdapter.FunctionNames.GET_PLAYLISTS, (), f.result(), ) future.add_done_callback(future_finished) @@ -216,9 +212,10 @@ class AdapterManager: def can_get_playlist_details() -> bool: # It only matters that the ground truth one can service the request. return ( - AdapterManager._instance.ground_truth_adapter.can_service_requests - and AdapterManager._instance.ground_truth_adapter - .can_get_playlist_details) + AdapterManager._instance is not None + and AdapterManager._instance.ground_truth_adapter.can_service_requests + and AdapterManager._instance.ground_truth_adapter.can_get_playlist_details + ) @staticmethod def get_playlist_details( @@ -228,14 +225,18 @@ class AdapterManager: ) -> Result[PlaylistDetails]: assert AdapterManager._instance partial_playlist_data = None - if (not force and AdapterManager._instance.caching_adapter and - AdapterManager._instance.caching_adapter.can_service_requests - and AdapterManager._instance.caching_adapter - .can_get_playlist_details): + if ( + not force + and AdapterManager._instance.caching_adapter + and AdapterManager._instance.caching_adapter.can_service_requests + and AdapterManager._instance.caching_adapter.can_get_playlist_details + ): try: return Result( - AdapterManager._instance.caching_adapter - .get_playlist_details(playlist_id)) + AdapterManager._instance.caching_adapter.get_playlist_details( + playlist_id + ) + ) except CacheMissError as e: partial_playlist_data = e.partial_data logging.debug(f'Cache Miss on {"get_playlist_details"}.') @@ -244,10 +245,13 @@ class AdapterManager: f'Error on {"get_playlist_details"} retrieving from cache.' ) - if (AdapterManager._instance.ground_truth_adapter - and not AdapterManager._instance.ground_truth_adapter - .can_service_requests and not AdapterManager._instance - .ground_truth_adapter.can_get_playlist_details): + if ( + AdapterManager._instance.ground_truth_adapter + and not AdapterManager._instance.ground_truth_adapter.can_service_requests + and not ( + AdapterManager._instance.ground_truth_adapter.can_get_playlist_details + ) + ): if partial_playlist_data: # TODO do something here pass @@ -259,9 +263,9 @@ class AdapterManager: assert AdapterManager._instance if before_download: before_download() - return ( - AdapterManager._instance.ground_truth_adapter - .get_playlist_details(playlist_id)) + return AdapterManager._instance.ground_truth_adapter.get_playlist_details( + playlist_id + ) future: Result[PlaylistDetails] = Result(future_fn) @@ -272,7 +276,7 @@ class AdapterManager: assert AdapterManager._instance.caching_adapter AdapterManager._instance.caching_adapter.ingest_new_data( CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS, - (playlist_id, ), + (playlist_id,), f.result(), ) diff --git a/sublime/adapters/api_objects.py b/sublime/adapters/api_objects.py index 3fe8d82..9160b9d 100644 --- a/sublime/adapters/api_objects.py +++ b/sublime/adapters/api_objects.py @@ -8,10 +8,10 @@ from typing import Optional, Sequence class MediaType(Enum): - MUSIC = 'music' - PODCAST = 'podcast' - AUDIOBOOK = 'audiobook' - VIDEO = 'video' + MUSIC = "music" + PODCAST = "podcast" + AUDIOBOOK = "audiobook" + VIDEO = "video" class Song(abc.ABC): diff --git a/sublime/adapters/filesystem/__init__.py b/sublime/adapters/filesystem/__init__.py index e34f5e8..ca073d9 100644 --- a/sublime/adapters/filesystem/__init__.py +++ b/sublime/adapters/filesystem/__init__.py @@ -1,3 +1,3 @@ from .adapter import FilesystemAdapter -__all__ = ('FilesystemAdapter', ) +__all__ = ("FilesystemAdapter",) diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py index c4a5f6d..dc3a17e 100644 --- a/sublime/adapters/filesystem/adapter.py +++ b/sublime/adapters/filesystem/adapter.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional, Sequence, Tuple -from sublime.adapters.api_objects import (Playlist, PlaylistDetails) +from sublime.adapters.api_objects import Playlist, PlaylistDetails from . import models from .. import CacheMissError, CachingAdapter, ConfigParamDescriptor @@ -16,6 +16,7 @@ class FilesystemAdapter(CachingAdapter): """ Defines an adapter which retrieves its data from the local filesystem. """ + # Configuration and Initialization Properties # ========================================================================= @staticmethod @@ -25,19 +26,15 @@ class FilesystemAdapter(CachingAdapter): } @staticmethod - def verify_configuration( - config: Dict[str, Any]) -> Dict[str, Optional[str]]: + def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]: return {} def __init__( - self, - config: dict, - data_directory: Path, - is_cache: bool = False, + self, config: dict, data_directory: Path, is_cache: bool = False, ): self.data_directory = data_directory self.is_cache = is_cache - database_filename = data_directory.joinpath('cache.db') + database_filename = data_directory.joinpath("cache.db") models.database.init(database_filename) models.database.connect() @@ -45,7 +42,7 @@ class FilesystemAdapter(CachingAdapter): models.database.create_tables(models.ALL_TABLES) def shutdown(self): - logging.info('Shutdown complete') + logging.info("Shutdown complete") # Usage Properties # ========================================================================= @@ -59,7 +56,7 @@ class FilesystemAdapter(CachingAdapter): # Data Helper Methods # ========================================================================= def _params_hash(self, *params: Any) -> str: - return hashlib.sha1(bytes(json.dumps(params), 'utf8')).hexdigest() + return hashlib.sha1(bytes(json.dumps(params), "utf8")).hexdigest() # Data Retrieval Methods # ========================================================================= @@ -74,20 +71,17 @@ class FilesystemAdapter(CachingAdapter): # not, cache miss. function_name = CachingAdapter.FunctionNames.GET_PLAYLISTS if not models.CacheInfo.get_or_none( - models.CacheInfo.query_name == function_name): + models.CacheInfo.query_name == function_name + ): raise CacheMissError() return playlists can_get_playlist_details: bool = True - def get_playlist_details( - self, - playlist_id: str, - ) -> PlaylistDetails: - playlist = models.Playlist.get_or_none( - models.Playlist.id == playlist_id) + def get_playlist_details(self, playlist_id: str,) -> PlaylistDetails: + playlist = models.Playlist.get_or_none(models.Playlist.id == playlist_id) if not playlist and not self.is_cache: - raise Exception(f'Playlist {playlist_id} does not exist.') + raise Exception(f"Playlist {playlist_id} does not exist.") # If we haven't ingested data for this playlist before, raise a # CacheMissError with the partial playlist data. @@ -106,11 +100,11 @@ class FilesystemAdapter(CachingAdapter): @models.database.atomic() def ingest_new_data( self, - function: 'CachingAdapter.FunctionNames', + function: "CachingAdapter.FunctionNames", params: Tuple[Any, ...], data: Any, ): - assert self.is_cache, 'FilesystemAdapter is not in cache mode' + assert self.is_cache, "FilesystemAdapter is not in cache mode" models.CacheInfo.insert( query_name=function, @@ -119,24 +113,25 @@ class FilesystemAdapter(CachingAdapter): ).on_conflict_replace().execute() if function == CachingAdapter.FunctionNames.GET_PLAYLISTS: - models.Playlist.insert_many(map( - asdict, data)).on_conflict_replace().execute() + models.Playlist.insert_many( + map(asdict, data) + ).on_conflict_replace().execute() elif function == CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS: playlist_data = asdict(data) playlist, playlist_created = models.Playlist.get_or_create( - id=playlist_data['id'], - defaults=playlist_data, + id=playlist_data["id"], defaults=playlist_data, ) # Handle the songs. songs = [] - for index, song_data in enumerate(playlist_data['songs']): + for index, song_data in enumerate(playlist_data["songs"]): # args = dict(filter(lambda kv: kv[0] in f, song_data.items())) - song_data['index'] = index + song_data["index"] = index song, song_created = models.Song.get_or_create( - id=song_data['id'], defaults=song_data) + id=song_data["id"], defaults=song_data + ) - keys = ('title', 'duration', 'path', 'index') + keys = ("title", "duration", "path", "index") if not song_created: for key in keys: setattr(song, key, song_data[key]) @@ -145,7 +140,7 @@ class FilesystemAdapter(CachingAdapter): songs.append(song) playlist.songs = songs - del playlist_data['songs'] + del playlist_data["songs"] # Update the values if the playlist already existed. if not playlist_created: diff --git a/sublime/adapters/filesystem/models.py b/sublime/adapters/filesystem/models.py index 1e7190a..391f855 100644 --- a/sublime/adapters/filesystem/models.py +++ b/sublime/adapters/filesystem/models.py @@ -70,7 +70,7 @@ class CacheInfo(BaseModel): last_ingestion_time = TzDateTimeField(null=False) class Meta: - primary_key = CompositeKey('query_name', 'params_hash') + primary_key = CompositeKey("query_name", "params_hash") class Playlist(BaseModel): @@ -85,7 +85,7 @@ class Playlist(BaseModel): public = BooleanField(null=True) cover_art = TextField(null=True) - songs = SortedManyToManyField(Song, backref='playlists') + songs = SortedManyToManyField(Song, backref="playlists") ALL_TABLES = ( diff --git a/sublime/adapters/filesystem/sqlite_extensions.py b/sublime/adapters/filesystem/sqlite_extensions.py index 074b586..05720c5 100644 --- a/sublime/adapters/filesystem/sqlite_extensions.py +++ b/sublime/adapters/filesystem/sqlite_extensions.py @@ -53,7 +53,7 @@ class SortedManyToManyQuery(ManyToManyQuery): accessor = self._accessor src_id = getattr(self._instance, self._src_attr) if isinstance(value, SelectQuery): - print('TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT') + print("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT") raise NotImplementedError() # query = value.columns(Value(src_id), accessor.dest_fk.rel_field) # accessor.through_model.insert_from( @@ -68,13 +68,14 @@ class SortedManyToManyQuery(ManyToManyQuery): { accessor.src_fk.name: src_id, accessor.dest_fk.name: rel_id, - 'position': i, - } for i, rel_id in enumerate(self._id_list(value)) + "position": i, + } + for i, rel_id in enumerate(self._id_list(value)) ] accessor.through_model.insert_many(inserts).execute() def remove(self, value: Any) -> Any: - print('RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR') + print("RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR") raise NotImplementedError() # src_id = getattr(self._instance, self._src_attr) # if isinstance(value, SelectQuery): @@ -102,23 +103,22 @@ class SortedManyToManyQuery(ManyToManyQuery): class SortedManyToManyFieldAccessor(ManyToManyFieldAccessor): def __get__( - self, - instance: Model, - instance_type: Any = None, - force_query: bool = False, + self, instance: Model, instance_type: Any = None, force_query: bool = False, ): if instance is not None: - if not force_query and self.src_fk.backref != '+': + if not force_query and self.src_fk.backref != "+": backref = getattr(instance, self.src_fk.backref) if isinstance(backref, list): return [getattr(obj, self.dest_fk.name) for obj in backref] src_id = getattr(instance, self.src_fk.rel_field.name) - return SortedManyToManyQuery(instance, self, self.rel_model) \ - .join(self.through_model) \ - .join(self.model) \ - .where(self.src_fk == src_id) \ + return ( + SortedManyToManyQuery(instance, self, self.rel_model) + .join(self.through_model) + .join(self.model) + .where(self.src_fk == src_id) .order_by(self.through_model.position) + ) return self.field @@ -137,16 +137,16 @@ class SortedManyToManyField(ManyToManyField): class Meta: database = self.model._meta.database schema = self.model._meta.schema - table_name = '{}_{}_through'.format(*tables) - indexes = (((lhs._meta.name, rhs._meta.name, 'position'), True), ) + table_name = "{}_{}_through".format(*tables) + indexes = (((lhs._meta.name, rhs._meta.name, "position"), True),) - params = {'on_delete': self._on_delete, 'on_update': self._on_update} + params = {"on_delete": self._on_delete, "on_update": self._on_update} attrs = { lhs._meta.name: ForeignKeyField(lhs, **params), rhs._meta.name: ForeignKeyField(rhs, **params), - 'position': IntegerField(), - 'Meta': Meta + "position": IntegerField(), + "Meta": Meta, } - klass_name = '{}{}Through'.format(lhs.__name__, rhs.__name__) - return type(klass_name, (Model, ), attrs) + klass_name = "{}{}Through".format(lhs.__name__, rhs.__name__) + return type(klass_name, (Model,), attrs) diff --git a/sublime/adapters/subsonic/__init__.py b/sublime/adapters/subsonic/__init__.py index 36c3631..8ec6007 100644 --- a/sublime/adapters/subsonic/__init__.py +++ b/sublime/adapters/subsonic/__init__.py @@ -1,3 +1,3 @@ from .adapter import SubsonicAdapter -__all__ = ('SubsonicAdapter', ) +__all__ = ("SubsonicAdapter",) diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index f66f42b..49c2a57 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -16,34 +16,30 @@ class SubsonicAdapter(Adapter): """ Defines an adapter which retrieves its data from a Subsonic server """ + # Configuration and Initialization Properties # ========================================================================= @staticmethod def get_config_parameters() -> Dict[str, ConfigParamDescriptor]: return { - 'server_address': - ConfigParamDescriptor(str, 'Server address'), - 'username': - ConfigParamDescriptor(str, 'Username'), - 'password': - ConfigParamDescriptor('password', 'Password'), - 'disable_cert_verify': - ConfigParamDescriptor('password', 'Password', False), + "server_address": ConfigParamDescriptor(str, "Server address"), + "username": ConfigParamDescriptor(str, "Username"), + "password": ConfigParamDescriptor("password", "Password"), + "disable_cert_verify": ConfigParamDescriptor("password", "Password", False), } @staticmethod - def verify_configuration( - config: Dict[str, Any]) -> Dict[str, Optional[str]]: + def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]: errors: Dict[str, Optional[str]] = {} # TODO: verify the URL return errors def __init__(self, config: dict, data_directory: Path): - self.hostname = config['server_address'] - self.username = config['username'] - self.password = config['password'] - self.disable_cert_verify = config.get('disable_cert_verify') + self.hostname = config["server_address"] + self.username = config["username"] + self.password = config["password"] + self.disable_cert_verify = config.get("disable_cert_verify") # TODO support XML | JSON @@ -53,44 +49,45 @@ class SubsonicAdapter(Adapter): def can_service_requests(self) -> bool: try: # Try to ping the server with a timeout of 2 seconds. - self._get_json(self._make_url('ping'), timeout=2) + self._get_json(self._make_url("ping"), timeout=2) return True except Exception: - logging.exception(f'Could not connect to {self.hostname}') + logging.exception(f"Could not connect to {self.hostname}") return False # Helper mothods for making requests # ========================================================================= def _get_params(self) -> Dict[str, str]: """ - Gets the parameters that are needed for all requests to the Subsonic - API. See Subsonic API Introduction for details. + Gets the parameters that are needed for all requests to the Subsonic API. See + Subsonic API Introduction for details. """ return { - 'u': self.username, - 'p': self.password, - 'c': 'Sublime Music', - 'f': 'json', - 'v': '1.15.0', + "u": self.username, + "p": self.password, + "c": "Sublime Music", + "f": "json", + "v": "1.15.0", } def _make_url(self, endpoint: str) -> str: - return f'{self.hostname}/rest/{endpoint}.view' + return f"{self.hostname}/rest/{endpoint}.view" def _get( - self, - url: str, - timeout: Union[float, Tuple[float, float], None] = None, - **params, + self, + url: str, + timeout: Union[float, Tuple[float, float], None] = None, + **params, ) -> Any: params = {**self._get_params(), **params} - logging.info(f'[START] get: {url}') + logging.info(f"[START] get: {url}") - if os.environ.get('SUBLIME_MUSIC_DEBUG_DELAY'): + if os.environ.get("SUBLIME_MUSIC_DEBUG_DELAY"): logging.info( "SUBLIME_MUSIC_DEBUG_DELAY enabled. Pausing for " - f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds.") - sleep(float(os.environ['SUBLIME_MUSIC_DEBUG_DELAY'])) + f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds." + ) + sleep(float(os.environ["SUBLIME_MUSIC_DEBUG_DELAY"])) # Deal with datetime parameters (convert to milliseconds since 1970) for k, v in params.items(): @@ -98,21 +95,18 @@ class SubsonicAdapter(Adapter): params[k] = int(v.timestamp() * 1000) if self._is_mock: - logging.debug('Using mock data') + logging.debug("Using mock data") return self._get_mock_data() result = requests.get( - url, - params=params, - verify=not self.disable_cert_verify, - timeout=timeout, + url, params=params, verify=not self.disable_cert_verify, timeout=timeout ) # TODO (#122): make better if result.status_code != 200: - raise Exception(f'[FAIL] get: {url} status={result.status_code}') + raise Exception(f"[FAIL] get: {url} status={result.status_code}") - logging.info(f'[FINISH] get: {url}') + logging.info(f"[FINISH] get: {url}") return result def _get_json( @@ -122,27 +116,27 @@ class SubsonicAdapter(Adapter): **params: Union[None, str, datetime, int, Sequence[int]], ) -> Response: """ - Make a get request to a *Sonic REST API. Handle all types of errors - including *Sonic ```` responses. + Make a get request to a *Sonic REST API. Handle all types of errors including + *Sonic ```` responses. :returns: a dictionary of the subsonic response. :raises Exception: needs some work TODO """ result = self._get(url, timeout=timeout, **params) - subsonic_response = result.json().get('subsonic-response') + subsonic_response = result.json().get("subsonic-response") # TODO (#122): make better if not subsonic_response: - raise Exception(f'[FAIL] get: invalid JSON from {url}') + raise Exception(f"[FAIL] get: invalid JSON from {url}") - if subsonic_response['status'] == 'failed': + if subsonic_response["status"] == "failed": code, message = ( - subsonic_response['error'].get('code'), - subsonic_response['error'].get('message'), + subsonic_response["error"].get("code"), + subsonic_response["error"].get("message"), ) - raise Exception(f'Subsonic API Error #{code}: {message}') + raise Exception(f"Subsonic API Error #{code}: {message}") - logging.debug(f'Response from {url}', subsonic_response) + logging.debug(f"Response from {url}", subsonic_response) return Response.from_dict(subsonic_response) # Helper Methods for Testing @@ -172,21 +166,15 @@ class SubsonicAdapter(Adapter): can_get_playlists = True def get_playlists(self) -> Sequence[API.Playlist]: - response = self._get_json(self._make_url('getPlaylists')).playlists + response = self._get_json(self._make_url("getPlaylists")).playlists if not response: return [] return response.playlist can_get_playlist_details = True - def get_playlist_details( - self, - playlist_id: str, - ) -> API.PlaylistDetails: - result = self._get_json( - self._make_url('getPlaylist'), - id=playlist_id, - ).playlist + def get_playlist_details(self, playlist_id: str,) -> API.PlaylistDetails: + result = self._get_json(self._make_url("getPlaylist"), id=playlist_id,).playlist # TODO better error - assert result, f'Error getting playlist {playlist_id}' + assert result, f"Error getting playlist {playlist_id}" return result diff --git a/sublime/adapters/subsonic/api_objects.py b/sublime/adapters/subsonic/api_objects.py index 4f7bfcc..3fb2bc8 100644 --- a/sublime/adapters/subsonic/api_objects.py +++ b/sublime/adapters/subsonic/api_objects.py @@ -18,15 +18,13 @@ from .. import api_objects as SublimeAPI # Translation map extra_translation_map = { - datetime: - (lambda s: datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%f%z') if s else None), + datetime: (lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%f%z") if s else None), timedelta: (lambda s: timedelta(seconds=s) if s else None), } for type_, translation_function in extra_translation_map.items(): dataclasses_json.cfg.global_config.decoders[type_] = translation_function - dataclasses_json.cfg.global_config.decoders[ - Optional[type_]] = translation_function + dataclasses_json.cfg.global_config.decoders[Optional[type_]] = translation_function @dataclass_json(letter_case=LetterCase.CAMEL) @@ -81,8 +79,7 @@ class Playlist(SublimeAPI.Playlist): class PlaylistWithSongs(SublimeAPI.PlaylistDetails): id: str name: str - songs: List[Song] = field( - default_factory=list, metadata=config(field_name='entry')) + songs: List[Song] = field(default_factory=list, metadata=config(field_name="entry")) song_count: int = field(default=0) duration: timedelta = field(default=timedelta()) created: Optional[datetime] = None @@ -96,8 +93,9 @@ class PlaylistWithSongs(SublimeAPI.PlaylistDetails): self.song_count = self.song_count or len(self.songs) self.duration = self.duration or timedelta( seconds=sum( - s.duration.total_seconds() if s.duration else 0 - for s in self.songs)) + s.duration.total_seconds() if s.duration else 0 for s in self.songs + ) + ) @dataclass @@ -107,9 +105,8 @@ class Playlists(DataClassJsonMixin): @dataclass class Response(DataClassJsonMixin): - """ - The base Subsonic response object. - """ + """The base Subsonic response object.""" + song: Optional[Song] = None playlists: Optional[Playlists] = None playlist: Optional[PlaylistWithSongs] = None diff --git a/sublime/app.py b/sublime/app.py index b307a1a..27af69a 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -9,22 +9,27 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple try: import osxmmkeys + tap_imported = True except Exception: tap_imported = False import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk + try: - gi.require_version('Notify', '0.7') + gi.require_version("Notify", "0.7") from gi.repository import Notify + glib_notify_exists = True except Exception: # I really don't care what kind of exception it is, all that matters is the # import failed for some reason. logging.warning( - 'Unable to import Notify from GLib. Notifications will be disabled.') + "Unable to import Notify from GLib. Notifications will be disabled." + ) glib_notify_exists = False from .adapters import AdapterManager @@ -44,14 +49,14 @@ class SublimeMusicApp(Gtk.Application): def __init__(self, config_file: Path): super().__init__(application_id="com.sumnerevans.sublimemusic") if glib_notify_exists: - Notify.init('Sublime Music') + Notify.init("Sublime Music") self.window: Optional[Gtk.Window] = None self.app_config = AppConfiguration.load_from_file(config_file) self.player = None self.dbus_manager: Optional[DBusManager] = None - self.connect('shutdown', self.on_app_shutdown) + self.connect("shutdown", self.on_app_shutdown) def do_startup(self): Gtk.Application.do_startup(self) @@ -61,40 +66,39 @@ class SublimeMusicApp(Gtk.Application): if type(parameter_type) == str: parameter_type = GLib.VariantType(parameter_type) action = Gio.SimpleAction.new(name, parameter_type) - action.connect('activate', fn) + action.connect("activate", fn) self.add_action(action) # Add action for menu items. - add_action('configure-servers', self.on_configure_servers) - add_action('settings', self.on_settings) + add_action("configure-servers", self.on_configure_servers) + add_action("settings", self.on_settings) # Add actions for player controls - add_action('play-pause', self.on_play_pause) - add_action('next-track', self.on_next_track) - add_action('prev-track', self.on_prev_track) - add_action('repeat-press', self.on_repeat_press) - add_action('shuffle-press', self.on_shuffle_press) + add_action("play-pause", self.on_play_pause) + add_action("next-track", self.on_next_track) + add_action("prev-track", self.on_prev_track) + add_action("repeat-press", self.on_repeat_press) + add_action("shuffle-press", self.on_shuffle_press) # Navigation actions. - add_action('play-next', self.on_play_next, parameter_type='as') - add_action('add-to-queue', self.on_add_to_queue, parameter_type='as') - add_action('go-to-album', self.on_go_to_album, parameter_type='s') - add_action('go-to-artist', self.on_go_to_artist, parameter_type='s') - add_action('browse-to', self.browse_to, parameter_type='s') - add_action( - 'go-to-playlist', self.on_go_to_playlist, parameter_type='s') + add_action("play-next", self.on_play_next, parameter_type="as") + add_action("add-to-queue", self.on_add_to_queue, parameter_type="as") + add_action("go-to-album", self.on_go_to_album, parameter_type="s") + add_action("go-to-artist", self.on_go_to_artist, parameter_type="s") + add_action("browse-to", self.browse_to, parameter_type="s") + add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s") - add_action('mute-toggle', self.on_mute_toggle) + add_action("mute-toggle", self.on_mute_toggle) add_action( - 'update-play-queue-from-server', + "update-play-queue-from-server", lambda a, p: self.update_play_state_from_server(), ) if tap_imported: self.tap = osxmmkeys.Tap() - self.tap.on('play_pause', self.on_play_pause) - self.tap.on('next_track', self.on_next_track) - self.tap.on('prev_track', self.on_prev_track) + self.tap.on("play_pause", self.on_play_pause) + self.tap.on("next_track", self.on_next_track) + self.tap.on("prev_track", self.on_prev_track) self.tap.start() def do_activate(self): @@ -111,26 +115,25 @@ class SublimeMusicApp(Gtk.Application): # window. css_provider = Gtk.CssProvider() css_provider.load_from_path( - os.path.join(os.path.dirname(__file__), 'ui/app_styles.css')) + os.path.join(os.path.dirname(__file__), "ui/app_styles.css") + ) context = Gtk.StyleContext() screen = Gdk.Screen.get_default() context.add_provider_for_screen( - screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) self.window.stack.connect( - 'notify::visible-child', - self.on_stack_change, + "notify::visible-child", self.on_stack_change, ) - self.window.connect('song-clicked', self.on_song_clicked) - self.window.connect('songs-removed', self.on_songs_removed) - self.window.connect('refresh-window', self.on_refresh_window) - self.window.connect('go-to', self.on_window_go_to) - self.window.connect('key-press-event', self.on_window_key_press) - self.window.player_controls.connect('song-scrub', self.on_song_scrub) - self.window.player_controls.connect( - 'device-update', self.on_device_update) - self.window.player_controls.connect( - 'volume-change', self.on_volume_change) + self.window.connect("song-clicked", self.on_song_clicked) + self.window.connect("songs-removed", self.on_songs_removed) + self.window.connect("refresh-window", self.on_refresh_window) + self.window.connect("go-to", self.on_window_go_to) + self.window.connect("key-press-event", self.on_window_key_press) + self.window.player_controls.connect("song-scrub", self.on_song_scrub) + self.window.player_controls.connect("device-update", self.on_device_update) + self.window.player_controls.connect("volume-change", self.on_volume_change) self.window.show_all() self.window.present() @@ -155,8 +158,11 @@ class SublimeMusicApp(Gtk.Application): self.should_scrobble_song = False def time_observer(value: Optional[float]): - if (self.loading_state or not self.window - or not self.app_config.state.current_song): + if ( + self.loading_state + or not self.window + or not self.app_config.state.current_song + ): return if value is None: @@ -178,10 +184,11 @@ class SublimeMusicApp(Gtk.Application): self.should_scrobble_song = False def on_track_end(): - at_end = self.app_config.state.current_song_index == len( - self.app_config.state.play_queue) - 1 - no_repeat = ( - self.app_config.state.repeat_type == RepeatType.NO_REPEAT) + at_end = ( + self.app_config.state.current_song_index + == len(self.app_config.state.play_queue) - 1 + ) + no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT if at_end and no_repeat: self.app_config.state.playing = False self.app_config.state.current_song_index = -1 @@ -192,32 +199,26 @@ class SublimeMusicApp(Gtk.Application): @dbus_propagate(self) def on_player_event(event: PlayerEvent): - if event.name == 'play_state_change': + if event.name == "play_state_change": self.app_config.state.playing = event.value - elif event.name == 'volume_change': + elif event.name == "volume_change": self.app_config.state.volume = event.value GLib.idle_add(self.update_window) self.mpv_player = MPVPlayer( - time_observer, - on_track_end, - on_player_event, - self.app_config, + time_observer, on_track_end, on_player_event, self.app_config, ) self.chromecast_player = ChromecastPlayer( - time_observer, - on_track_end, - on_player_event, - self.app_config, + time_observer, on_track_end, on_player_event, self.app_config, ) self.player = self.mpv_player - if self.app_config.state.current_device != 'this device': + if self.app_config.state.current_device != "this device": # TODO (#120) pass - self.app_config.state.current_device = 'this device' + self.app_config.state.current_device = "this device" # Need to do this after we set the current device. self.player.volume = self.app_config.state.volume @@ -231,8 +232,7 @@ class SublimeMusicApp(Gtk.Application): self.dbus_manager.property_diff() # ########## DBUS MANAGMENT ########## # - def do_dbus_register( - self, connection: Gio.DBusConnection, path: str) -> bool: + def do_dbus_register(self, connection: Gio.DBusConnection, path: str) -> bool: self.dbus_manager = DBusManager( connection, self.on_dbus_method_call, @@ -264,9 +264,7 @@ class SublimeMusicApp(Gtk.Application): # it could be a directory. assert self.app_config.state.current_song.duration is not None self.on_song_scrub( - None, - new_seconds / self.app_config.state.current_song.duration - * 100, + None, new_seconds / self.app_config.state.current_song.duration * 100, ) def set_pos_fn(track_id: str, position: float = 0): @@ -274,12 +272,13 @@ class SublimeMusicApp(Gtk.Application): self.on_play_pause() pos_seconds = position / second_microsecond_conversion self.app_config.state.song_progress = pos_seconds - track_id, occurrence = track_id.split('/')[-2:] + track_id, occurrence = track_id.split("/")[-2:] # Find the (N-1)th time that the track id shows up in the list. (N # is the -*** suffix on the track id.) song_index = [ - i for i, x in enumerate(self.app_config.state.play_queue) + i + for i, x in enumerate(self.app_config.state.play_queue) if x == track_id ][int(occurrence) or 0] @@ -291,42 +290,42 @@ class SublimeMusicApp(Gtk.Application): if len(track_ids) == 0: # We are lucky, just return an empty list. - return GLib.Variant('(aa{sv})', ([], )) + return GLib.Variant("(aa{sv})", ([],)) # Have to calculate all of the metadatas so that we can deal with # repeat song IDs. metadatas: Iterable[Any] = [ self.dbus_manager.get_mpris_metadata( - i, - self.app_config.state.play_queue, - ) for i in range(len(self.app_config.state.play_queue)) + i, self.app_config.state.play_queue, + ) + for i in range(len(self.app_config.state.play_queue)) ] # Get rid of all of the tracks that were not requested. metadatas = list( - filter(lambda m: m['mpris:trackid'] in track_ids, metadatas)) + filter(lambda m: m["mpris:trackid"] in track_ids, metadatas) + ) assert len(metadatas) == len(track_ids) # Sort them so they get returned in the same order as they were # requested. metadatas = sorted( - metadatas, key=lambda m: track_ids.index(m['mpris:trackid'])) + metadatas, key=lambda m: track_ids.index(m["mpris:trackid"]) + ) # Turn them into dictionaries that can actually be serialized into # a GLib.Variant. metadatas = map( - lambda m: {k: DBusManager.to_variant(v) - for k, v in m.items()}, + lambda m: {k: DBusManager.to_variant(v) for k, v in m.items()}, metadatas, ) - return GLib.Variant('(aa{sv})', (list(metadatas), )) + return GLib.Variant("(aa{sv})", (list(metadatas),)) def activate_playlist(playlist_id: str): - playlist_id = playlist_id.split('/')[-1] - playlist = AdapterManager.get_playlist_details( - playlist_id).result() + playlist_id = playlist_id.split("/")[-1] + playlist = AdapterManager.get_playlist_details(playlist_id).result() # Calculate the song id to play. song_idx = 0 @@ -337,46 +336,44 @@ class SublimeMusicApp(Gtk.Application): None, song_idx, [s.id for s in playlist.songs], - {'active_playlist_id': playlist_id}, + {"active_playlist_id": playlist_id}, ) def get_playlists( - index: int, - max_count: int, - order: str, - reverse_order: bool, + index: int, max_count: int, order: str, reverse_order: bool, ) -> GLib.Variant: playlists_result = AdapterManager.get_playlists() if not playlists_result.data_is_available: # We don't want to wait for the response in this case, so just # return an empty array. - return GLib.Variant('(a(oss))', ([], )) + return GLib.Variant("(a(oss))", ([],)) playlists = list(playlists_result.result()) sorters = { - 'Alphabetical': lambda p: p.name, - 'Created': lambda p: p.created, - 'Modified': lambda p: p.changed, + "Alphabetical": lambda p: p.name, + "Created": lambda p: p.created, + "Modified": lambda p: p.changed, } playlists.sort( - key=sorters.get(order, lambda p: p), - reverse=reverse_order, + key=sorters.get(order, lambda p: p), reverse=reverse_order, ) def make_playlist_tuple(p: Playlist) -> GLib.Variant: cover_art_filename = CacheManager.get_cover_art_filename( - p.cover_art, - allow_download=False, + p.cover_art, allow_download=False, ).result() - return (f'/playlist/{p.id}', p.name, cover_art_filename or '') + return (f"/playlist/{p.id}", p.name, cover_art_filename or "") return GLib.Variant( - '(a(oss))', ( + "(a(oss))", + ( [ make_playlist_tuple(p) - for p in playlists[index:(index + max_count)] - ], )) + for p in playlists[index : (index + max_count)] + ], + ), + ) def play(): if not self.app_config.state.playing: @@ -387,36 +384,34 @@ class SublimeMusicApp(Gtk.Application): self.on_play_pause() method_call_map: Dict[str, Dict[str, Any]] = { - 'org.mpris.MediaPlayer2': { - 'Raise': self.window and self.window.present, - 'Quit': self.window and self.window.destroy, + "org.mpris.MediaPlayer2": { + "Raise": self.window and self.window.present, + "Quit": self.window and self.window.destroy, }, - 'org.mpris.MediaPlayer2.Player': { - 'Next': self.on_next_track, - 'Previous': self.on_prev_track, - 'Pause': pause, - 'PlayPause': self.on_play_pause, - 'Stop': pause, - 'Play': play, - 'Seek': seek_fn, - 'SetPosition': set_pos_fn, + "org.mpris.MediaPlayer2.Player": { + "Next": self.on_next_track, + "Previous": self.on_prev_track, + "Pause": pause, + "PlayPause": self.on_play_pause, + "Stop": pause, + "Play": play, + "Seek": seek_fn, + "SetPosition": set_pos_fn, }, - 'org.mpris.MediaPlayer2.TrackList': { - 'GoTo': set_pos_fn, - 'GetTracksMetadata': get_tracks_metadata, + "org.mpris.MediaPlayer2.TrackList": { + "GoTo": set_pos_fn, + "GetTracksMetadata": get_tracks_metadata, # 'RemoveTrack': remove_track, }, - 'org.mpris.MediaPlayer2.Playlists': { - 'ActivatePlaylist': activate_playlist, - 'GetPlaylists': get_playlists, + "org.mpris.MediaPlayer2.Playlists": { + "ActivatePlaylist": activate_playlist, + "GetPlaylists": get_playlists, }, } method_fn = method_call_map.get(interface, {}).get(method) if method_fn is None: - logging.warning( - f'Unknown/unimplemented method: {interface}.{method}.') - invocation.return_value( - method_fn(*params) if callable(method_fn) else None) + logging.warning(f"Unknown/unimplemented method: {interface}.{method}.") + invocation.return_value(method_fn(*params) if callable(method_fn) else None) def on_dbus_set_property( self, @@ -428,9 +423,9 @@ class SublimeMusicApp(Gtk.Application): value: GLib.Variant, ): def change_loop(new_loop_status: GLib.Variant): - self.app_config.state.repeat_type = ( - RepeatType.from_mpris_loop_status( - new_loop_status.get_string())) + self.app_config.state.repeat_type = RepeatType.from_mpris_loop_status( + new_loop_status.get_string() + ) self.update_window() def set_shuffle(new_val: GLib.Variant): @@ -441,17 +436,17 @@ class SublimeMusicApp(Gtk.Application): self.on_volume_change(None, new_val.get_double() * 100) setter_map: Dict[str, Dict[str, Any]] = { - 'org.mpris.MediaPlayer2.Player': { - 'LoopStatus': change_loop, - 'Rate': lambda _: None, - 'Shuffle': set_shuffle, - 'Volume': set_volume, + "org.mpris.MediaPlayer2.Player": { + "LoopStatus": change_loop, + "Rate": lambda _: None, + "Shuffle": set_shuffle, + "Volume": set_volume, } } setter = setter_map.get(interface, {}).get(property_name) if setter is None: - logging.warning('Set: Unknown property: {property_name}.') + logging.warning("Set: Unknown property: {property_name}.") return if callable(setter): setter(value) @@ -459,10 +454,7 @@ class SublimeMusicApp(Gtk.Application): # ########## ACTION HANDLERS ########## # @dbus_propagate() def on_refresh_window( - self, - _, - state_updates: Dict[str, Any], - force: bool = False, + self, _, state_updates: Dict[str, Any], force: bool = False, ): for k, v in state_updates.items(): setattr(self.app_config.state, k, v) @@ -476,32 +468,34 @@ class SublimeMusicApp(Gtk.Application): dialog = SettingsDialog(self.window, self.app_config) result = dialog.run() if result == Gtk.ResponseType.OK: - self.app_config.port_number = int( - dialog.data['port_number'].get_text()) - self.app_config.always_stream = dialog.data[ - 'always_stream'].get_active() + self.app_config.port_number = int(dialog.data["port_number"].get_text()) + self.app_config.always_stream = dialog.data["always_stream"].get_active() self.app_config.download_on_stream = dialog.data[ - 'download_on_stream'].get_active() + "download_on_stream" + ].get_active() self.app_config.song_play_notification = dialog.data[ - 'song_play_notification'].get_active() - self.app_config.serve_over_lan = dialog.data[ - 'serve_over_lan'].get_active() + "song_play_notification" + ].get_active() + self.app_config.serve_over_lan = dialog.data["serve_over_lan"].get_active() self.app_config.prefetch_amount = dialog.data[ - 'prefetch_amount'].get_value_as_int() + "prefetch_amount" + ].get_value_as_int() self.app_config.concurrent_download_limit = dialog.data[ - 'concurrent_download_limit'].get_value_as_int() + "concurrent_download_limit" + ].get_value_as_int() self.app_config.replay_gain = ReplayGainType.from_string( - dialog.data['replay_gain'].get_active_id()) + dialog.data["replay_gain"].get_active_id() + ) self.app_config.save() self.reset_state() dialog.destroy() def on_window_go_to(self, win: Any, action: str, value: str): { - 'album': self.on_go_to_album, - 'artist': self.on_go_to_artist, - 'playlist': self.on_go_to_playlist, - }[action](None, GLib.Variant('s', value)) + "album": self.on_go_to_album, + "artist": self.on_go_to_artist, + "playlist": self.on_go_to_playlist, + }[action](None, GLib.Variant("s", value)) @dbus_propagate() def on_play_pause(self, *args): @@ -523,8 +517,10 @@ class SublimeMusicApp(Gtk.Application): if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG: song_index_to_play = self.app_config.state.current_song_index # Wrap around the play queue if at the end. - elif self.app_config.state.current_song_index == len( - self.app_config.state.play_queue) - 1: + elif ( + self.app_config.state.current_song_index + == len(self.app_config.state.play_queue) - 1 + ): # This may happen due to D-Bus. if self.app_config.state.repeat_type == RepeatType.NO_REPEAT: return @@ -545,8 +541,8 @@ class SublimeMusicApp(Gtk.Application): song_index_to_play = 0 else: song_index_to_play = ( - self.app_config.state.current_song_index - 1) % len( - self.app_config.state.play_queue) + self.app_config.state.current_song_index - 1 + ) % len(self.app_config.state.play_queue) else: # Go back to the beginning of the song. song_index_to_play = self.app_config.state.current_song_index @@ -556,8 +552,7 @@ class SublimeMusicApp(Gtk.Application): @dbus_propagate() def on_repeat_press(self, *args): # Cycle through the repeat types. - new_repeat_type = RepeatType( - (self.app_config.state.repeat_type.value + 1) % 3) + new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3) self.app_config.state.repeat_type = new_repeat_type self.update_window() @@ -565,22 +560,26 @@ class SublimeMusicApp(Gtk.Application): def on_shuffle_press(self, *args): if self.app_config.state.shuffle_on: # Revert to the old play queue. - self.app_config.state.current_song_index = ( - self.app_config.state.old_play_queue.index( - self.app_config.state.current_song.id)) + self.app_config.state.current_song_index = self.app_config.state.old_play_queue.index( + self.app_config.state.current_song.id + ) self.app_config.state.play_queue = ( - self.app_config.state.old_play_queue.copy()) + self.app_config.state.old_play_queue.copy() + ) else: self.app_config.state.old_play_queue = ( - self.app_config.state.play_queue.copy()) + self.app_config.state.play_queue.copy() + ) # Remove the current song, then shuffle and put the song back. song_id = self.app_config.state.current_song.id del self.app_config.state.play_queue[ - self.app_config.state.current_song_index] + self.app_config.state.current_song_index + ] random.shuffle(self.app_config.state.play_queue) - self.app_config.state.play_queue = ( - [song_id] + self.app_config.state.play_queue) + self.app_config.state.play_queue = [ + song_id + ] + self.app_config.state.play_queue self.app_config.state.current_song_index = 0 self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on @@ -594,8 +593,10 @@ class SublimeMusicApp(Gtk.Application): insert_at = self.app_config.state.current_song_index + 1 self.app_config.state.play_queue = ( - self.app_config.state.play_queue[:insert_at] + list(song_ids) - + self.app_config.state.play_queue[insert_at:]) + self.app_config.state.play_queue[:insert_at] + + list(song_ids) + + self.app_config.state.play_queue[insert_at:] + ) self.app_config.state.old_play_queue.extend(song_ids) self.update_window() @@ -613,43 +614,43 @@ class SublimeMusicApp(Gtk.Application): if len(album.child) > 0: album = album.child[0] - if album.get('year'): - self.app_config.state.current_album_sort = 'byYear' - self.app_config.state.current_album_from_year = album.year - self.app_config.state.current_album_to_year = album.year - elif album.get('genre'): - self.app_config.state.current_album_sort = 'byGenre' - self.app_config.state.current_album_genre = album.genre + if year := album.get("year"): + self.app_config.state.current_album_sort = "byYear" + self.app_config.state.current_album_from_year = year + self.app_config.state.current_album_to_year = year + elif genre := album.get("genre"): + self.app_config.state.current_album_sort = "byGenre" + self.app_config.state.current_album_genre = genre else: dialog = Gtk.MessageDialog( transient_for=self.window, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, - text='Could not go to album', + text="Could not go to album", ) dialog.format_secondary_markup( - 'Could not go to the album because it does not have a year or ' - 'genre.') + "Could not go to the album because it does not have a year or " "genre." + ) dialog.run() dialog.destroy() return - self.app_config.state.current_tab = 'albums' + self.app_config.state.current_tab = "albums" self.app_config.state.selected_album_id = album_id.get_string() self.update_window(force=True) def on_go_to_artist(self, action: Any, artist_id: GLib.Variant): - self.app_config.state.current_tab = 'artists' + self.app_config.state.current_tab = "artists" self.app_config.state.selected_artist_id = artist_id.get_string() self.update_window() def browse_to(self, action: Any, item_id: GLib.Variant): - self.app_config.state.current_tab = 'browse' + self.app_config.state.current_tab = "browse" self.app_config.state.selected_browse_element_id = item_id.get_string() self.update_window() def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant): - self.app_config.state.current_tab = 'playlists' + self.app_config.state.current_tab = "playlists" self.app_config.state.selected_playlist_id = playlist_id.get_string() self.update_window() @@ -673,9 +674,7 @@ class SublimeMusicApp(Gtk.Application): self.app_config.save() def on_connected_server_changed( - self, - action: Any, - current_server_index: int, + self, action: Any, current_server_index: int, ): if self.app_config.server: self.app_config.save() @@ -710,18 +709,13 @@ class SublimeMusicApp(Gtk.Application): # previous one. old_play_queue = song_queue.copy() - if metadata.get('force_shuffle_state') is not None: - self.app_config.state.shuffle_on = metadata['force_shuffle_state'] + if (force_shuffle := metadata.get("force_shuffle_state")) is not None: + self.app_config.state.shuffle_on = force_shuffle - if metadata.get('active_playlist_id') is not None: - self.app_config.state.active_playlist_id = metadata.get( - 'active_playlist_id') - else: - self.app_config.state.active_playlist_id = None + self.app_config.state.active_playlist_id = metadata.get("active_playlist_id") # If shuffle is enabled, then shuffle the playlist. - if self.app_config.state.shuffle_on and not metadata.get( - 'no_reshuffle'): + if self.app_config.state.shuffle_on and not metadata.get("no_reshuffle"): song_id = song_queue[song_index] del song_queue[song_index] @@ -746,7 +740,8 @@ class SublimeMusicApp(Gtk.Application): # Determine how many songs before the currently playing one were also # deleted. before_current = [ - i for i in song_indexes_to_remove + i + for i in song_indexes_to_remove if i < self.app_config.state.current_song_index ] @@ -759,8 +754,7 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.current_song_index -= len(before_current) self.play_song( - self.app_config.state.current_song_index, - reset=True, + self.app_config.state.current_song_index, reset=True, ) else: self.app_config.state.current_song_index -= len(before_current) @@ -776,8 +770,7 @@ class SublimeMusicApp(Gtk.Application): # a duration, but the Child object has `duration` optional because # it could be a directory. assert self.app_config.state.current_song.duration is not None - new_time = self.app_config.state.current_song.duration * ( - scrub_value / 100) + new_time = self.app_config.state.current_song.duration * (scrub_value / 100) self.app_config.state.song_progress = new_time self.window.player_controls.update_scrubber( @@ -806,7 +799,7 @@ class SublimeMusicApp(Gtk.Application): self.update_window() - if device_uuid == 'this device': + if device_uuid == "this device": self.player = self.mpv_player else: self.chromecast_player.set_playing_chromecast(device_uuid) @@ -829,14 +822,9 @@ class SublimeMusicApp(Gtk.Application): self.player.volume = self.app_config.state.volume self.update_window() - def on_window_key_press( - self, - window: Gtk.Window, - event: Gdk.EventKey, - ) -> bool: + def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey,) -> bool: # Need to use bitwise & here to see if CTRL is pressed. - if (event.keyval == 102 - and event.state & Gdk.ModifierType.CONTROL_MASK): + if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK: # Ctrl + F window.search_entry.grab_focus() return False @@ -857,7 +845,7 @@ class SublimeMusicApp(Gtk.Application): return False - def on_app_shutdown(self, app: 'SublimeMusicApp'): + def on_app_shutdown(self, app: "SublimeMusicApp"): if glib_notify_exists: Notify.uninit() @@ -882,9 +870,8 @@ class SublimeMusicApp(Gtk.Application): def show_configure_servers_dialog(self): """Show the Connect to Server dialog.""" dialog = ConfigureServersDialog(self.window, self.app_config) - dialog.connect('server-list-changed', self.on_server_list_changed) - dialog.connect( - 'connected-server-changed', self.on_connected_server_changed) + dialog.connect("server-list-changed", self.on_server_list_changed) + dialog.connect("connected-server-changed", self.on_connected_server_changed) dialog.run() dialog.destroy() @@ -912,11 +899,13 @@ class SublimeMusicApp(Gtk.Application): progress_diff = 15 if self.app_config.state.song_progress: progress_diff = abs( - self.app_config.state.song_progress - - new_song_progress) + self.app_config.state.song_progress - new_song_progress + ) - if (self.app_config.state.play_queue == new_play_queue - and self.app_config.state.current_song): + if ( + self.app_config.state.play_queue == new_play_queue + and self.app_config.state.current_song + ): song_id = self.app_config.state.current_song.id if song_id == new_current_song_id and progress_diff < 15: return @@ -925,14 +914,18 @@ class SublimeMusicApp(Gtk.Application): transient_for=self.window, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.YES_NO, - text='Resume Playback?', + text="Resume Playback?", ) dialog.format_secondary_markup( - 'Do you want to resume the play queue saved by ' - + str(play_queue.changedBy) + ' at ' - + play_queue.changed.astimezone( - tz=None).strftime('%H:%M on %Y-%m-%d') + '?') + "Do you want to resume the play queue saved by " + + str(play_queue.changedBy) + + " at " + + play_queue.changed.astimezone(tz=None).strftime( + "%H:%M on %Y-%m-%d" + ) + + "?" + ) result = dialog.run() dialog.destroy() if result != Gtk.ResponseType.YES: @@ -941,8 +934,9 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.play_queue = new_play_queue self.app_config.state.song_progress = play_queue.position / 1000 - self.app_config.state.current_song_index = ( - self.app_config.state.play_queue.index(new_current_song_id)) + self.app_config.state.current_song_index = self.app_config.state.play_queue.index( + new_current_song_id + ) self.player.reset() self.update_window() @@ -951,8 +945,7 @@ class SublimeMusicApp(Gtk.Application): self.on_play_pause() play_queue_future = CacheManager.get_play_queue() - play_queue_future.add_done_callback( - lambda f: GLib.idle_add(do_update, f)) + play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f)) song_playing_order_token = 0 @@ -968,8 +961,7 @@ class SublimeMusicApp(Gtk.Application): @dbus_propagate(self) def do_play_song(song: Child): uri, stream = CacheManager.get_song_filename_or_stream( - song, - force_stream=self.app_config.always_stream, + song, force_stream=self.app_config.always_stream, ) # Prevent it from doing the thing where it continually loads # songs when it has to download. @@ -984,24 +976,21 @@ class SublimeMusicApp(Gtk.Application): if glib_notify_exists: notification_lines = [] if song.album: - notification_lines.append(f'{song.album}') + notification_lines.append(f"{song.album}") if song.artist: notification_lines.append(song.artist) song_notification = Notify.Notification.new( - song.title, - '\n'.join(notification_lines), + song.title, "\n".join(notification_lines), ) song_notification.add_action( - 'clicked', - 'Open Sublime Music', - lambda *a: self.window.present() - if self.window else None, + "clicked", + "Open Sublime Music", + lambda *a: self.window.present() if self.window else None, ) song_notification.show() def on_cover_art_download_complete( - cover_art_filename: str, - order_token: int, + cover_art_filename: str, order_token: int, ): if order_token != self.song_playing_order_token: return @@ -1010,51 +999,54 @@ class SublimeMusicApp(Gtk.Application): # the notification. song_notification.set_image_from_pixbuf( GdkPixbuf.Pixbuf.new_from_file_at_scale( - cover_art_filename, 70, 70, True)) + cover_art_filename, 70, 70, True + ) + ) song_notification.show() - def get_cover_art_filename( - order_token: int) -> Tuple[str, int]: + def get_cover_art_filename(order_token: int) -> Tuple[str, int]: return ( CacheManager.get_cover_art_filename( - song.coverArt).result(), + song.coverArt + ).result(), order_token, ) self.song_playing_order_token += 1 cover_art_future = CacheManager.create_future( - get_cover_art_filename, - self.song_playing_order_token, + get_cover_art_filename, self.song_playing_order_token, ) cover_art_future.add_done_callback( - lambda f: on_cover_art_download_complete( - *f.result())) - if sys.platform == 'darwin': + lambda f: on_cover_art_download_complete(*f.result()) + ) + if sys.platform == "darwin": notification_lines = [] if song.album: notification_lines.append(song.album) if song.artist: notification_lines.append(song.artist) - notification_text = '\n'.join(notification_lines) + notification_text = "\n".join(notification_lines) osascript_command = [ - 'display', - 'notification', + "display", + "notification", f'"{notification_text}"', - 'with', - 'title', + "with", + "title", f'"{song.title}"', ] - os.system( - f"osascript -e '{' '.join(osascript_command)}'") + os.system(f"osascript -e '{' '.join(osascript_command)}'") except Exception: logging.warning( - 'Unable to display notification. Is a notification ' - 'daemon running?') + "Unable to display notification. Is a notification " + "daemon running?" + ) def on_song_download_complete(song_id: int): - if (self.app_config.state.current_song - and self.app_config.state.current_song.id != song.id): + if ( + self.app_config.state.current_song + and self.app_config.state.current_song.id != song.id + ): return if not self.app_config.state.playing: return @@ -1071,8 +1063,11 @@ class SublimeMusicApp(Gtk.Application): # If streaming, also download the song, unless configured not to, # or configured to always stream. - if (stream and self.app_config.download_on_stream - and not self.app_config.always_stream): + if ( + stream + and self.app_config.download_on_stream + and not self.app_config.always_stream + ): CacheManager.batch_download_songs( [song.id], before_download=lambda: self.update_window(), @@ -1080,9 +1075,7 @@ class SublimeMusicApp(Gtk.Application): ) self.player.play_media( - uri, - 0 if reset else self.app_config.state.song_progress, - song, + uri, 0 if reset else self.app_config.state.song_progress, song, ) self.app_config.state.playing = True self.update_window() @@ -1096,17 +1089,16 @@ class SublimeMusicApp(Gtk.Application): for i in range(self.app_config.prefetch_amount): prefetch_idx: int = song_idx + 1 + i play_queue_len: int = len(self.app_config.state.play_queue) - if (is_repeat_queue or prefetch_idx < play_queue_len): + if is_repeat_queue or prefetch_idx < play_queue_len: prefetch_idxs.append( - prefetch_idx % play_queue_len) # noqa: S001 + prefetch_idx % play_queue_len + ) # noqa: S001 CacheManager.batch_download_songs( - [ - self.app_config.state.play_queue[i] - for i in prefetch_idxs - ], + [self.app_config.state.play_queue[i] for i in prefetch_idxs], before_download=lambda: GLib.idle_add(self.update_window), on_song_download_complete=lambda _: GLib.idle_add( - self.update_window), + self.update_window + ), ) if old_play_queue: @@ -1121,10 +1113,11 @@ class SublimeMusicApp(Gtk.Application): self.save_play_queue() song_details_future = CacheManager.get_song_details( - self.app_config.state.play_queue[ - self.app_config.state.current_song_index]) + self.app_config.state.play_queue[self.app_config.state.current_song_index] + ) song_details_future.add_done_callback( - lambda f: GLib.idle_add(do_play_song, f.result()), ) + lambda f: GLib.idle_add(do_play_song, f.result()), + ) def save_play_queue(self): if len(self.app_config.state.play_queue) == 0: @@ -1133,8 +1126,7 @@ class SublimeMusicApp(Gtk.Application): position = self.app_config.state.song_progress self.last_play_queue_update = position or 0 - if (self.app_config.server.sync_enabled - and self.app_config.state.current_song): + if self.app_config.server.sync_enabled and self.app_config.state.current_song: CacheManager.save_play_queue( play_queue=self.app_config.state.play_queue, current=self.app_config.state.current_song.id, diff --git a/sublime/cache_manager.py b/sublime/cache_manager.py index b1297ec..ed39328 100644 --- a/sublime/cache_manager.py +++ b/sublime/cache_manager.py @@ -34,14 +34,17 @@ from fuzzywuzzy import fuzz try: import gi - gi.require_version('NM', '1.0') + + gi.require_version("NM", "1.0") from gi.repository import NM + networkmanager_imported = True except Exception: # I really don't care what kind of exception it is, all that matters is the # import failed for some reason. logging.warning( - 'Unable to import NM from GLib. Detection of SSID will be disabled.') + "Unable to import NM from GLib. Detection of SSID will be disabled." + ) networkmanager_imported = False from .config import AppConfiguration @@ -67,6 +70,7 @@ class Singleton(type): Metaclass for :class:`CacheManager` so that it can be used like a singleton. """ + def __getattr__(cls, name: str) -> Any: if not CacheManager._instance: return None @@ -103,7 +107,7 @@ def similarity_ratio(query: str, string: str) -> int: return fuzz.partial_ratio(query.lower(), string.lower()) -S = TypeVar('S') +S = TypeVar("S") class SearchResult: @@ -111,6 +115,7 @@ class SearchResult: An object representing the aggregate results of a search which can include both server and local results. """ + _artist: Set[ArtistID3] = set() _album: Set[AlbumID3] = set() _song: Set[Child] = set() @@ -124,21 +129,15 @@ class SearchResult: if results is None: return - member = f'_{result_type}' + member = f"_{result_type}" if getattr(self, member) is None: setattr(self, member, set()) setattr( - self, - member, - getattr(getattr(self, member, set()), 'union')(set(results)), + self, member, getattr(getattr(self, member, set()), "union")(set(results)), ) - def _to_result( - self, - it: Iterable[S], - transform: Callable[[S], str], - ) -> List[S]: + def _to_result(self, it: Iterable[S], transform: Callable[[S], str],) -> List[S]: all_results = sorted( ((similarity_ratio(self.query, transform(x)), x) for x in it), key=lambda rx: rx[0], @@ -164,13 +163,13 @@ class SearchResult: if self._album is None: return None - return self._to_result(self._album, lambda a: f'{a.name} - {a.artist}') + return self._to_result(self._album, lambda a: f"{a.name} - {a.artist}") @property def song(self) -> Optional[List[Child]]: if self._song is None: return None - return self._to_result(self._song, lambda s: f'{s.title} - {s.artist}') + return self._to_result(self._song, lambda s: f"{s.title} - {s.artist}") @property def playlist(self) -> Optional[List[Playlist]]: @@ -179,13 +178,14 @@ class SearchResult: return self._to_result(self._playlist, lambda p: p.name) -T = TypeVar('T') +T = TypeVar("T") class CacheManager(metaclass=Singleton): """ Handles everything related to caching metadata and song files. """ + executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50) should_exit: bool = False @@ -205,8 +205,8 @@ class CacheManager(metaclass=Singleton): on_cancel: Optional[Callable[[], None]] = None @staticmethod - def from_data(data: T) -> 'CacheManager.Result[T]': - result: 'CacheManager.Result[T]' = CacheManager.Result() + def from_data(data: T) -> "CacheManager.Result[T]": + result: "CacheManager.Result[T]" = CacheManager.Result() result.data = data return result @@ -216,8 +216,8 @@ class CacheManager(metaclass=Singleton): before_download: Callable[[], Any] = None, after_download: Callable[[T], Any] = None, on_cancel: Callable[[], Any] = None, - ) -> 'CacheManager.Result[T]': - result: 'CacheManager.Result[T]' = CacheManager.Result() + ) -> "CacheManager.Result[T]": + result: "CacheManager.Result[T]" = CacheManager.Result() def future_fn() -> T: if before_download: @@ -229,7 +229,8 @@ class CacheManager(metaclass=Singleton): if after_download is not None: result.future.add_done_callback( - lambda f: after_download and after_download(f.result())) + lambda f: after_download and after_download(f.result()) + ) return result @@ -240,8 +241,8 @@ class CacheManager(metaclass=Singleton): return self.future.result() raise Exception( - 'CacheManager.Result did not have either a data or future ' - 'member.') + "CacheManager.Result did not have either a data or future " "member." + ) def add_done_callback(self, fn: Callable, *args): if self.future is not None: @@ -268,11 +269,11 @@ class CacheManager(metaclass=Singleton): @staticmethod def shutdown(): - logging.info('CacheManager shutdown start') + logging.info("CacheManager shutdown start") CacheManager.should_exit = True CacheManager.executor.shutdown() CacheManager._instance.save_cache_info() - logging.info('CacheManager shutdown complete') + logging.info("CacheManager shutdown complete") class CacheEncoder(json.JSONEncoder): def default(self, obj: Any) -> Optional[Union[int, List, Dict]]: @@ -331,7 +332,8 @@ class CacheManager(metaclass=Singleton): disable_cert_verify=self.app_config.server.disable_cert_verify, ) self.download_limiter_semaphore = threading.Semaphore( - self.app_config.concurrent_download_limit) + self.app_config.concurrent_download_limit + ) self.load_cache_info() @@ -346,7 +348,7 @@ class CacheManager(metaclass=Singleton): if not self.nmclient_initialized: # Only look at the active WiFi connections. for ac in self.networkmanager_client.get_active_connections(): - if ac.get_connection_type() != '802-11-wireless': + if ac.get_connection_type() != "802-11-wireless": continue devs = ac.get_devices() if len(devs) != 1: @@ -359,71 +361,65 @@ class CacheManager(metaclass=Singleton): return self._current_ssids def load_cache_info(self): - cache_meta_file = self.calculate_abs_path('.cache_meta') + cache_meta_file = self.calculate_abs_path(".cache_meta") meta_json = {} if cache_meta_file.exists(): - with open(cache_meta_file, 'r') as f: + with open(cache_meta_file, "r") as f: try: meta_json = json.load(f) except json.decoder.JSONDecodeError: # Just continue with the default meta_json. - logging.warning( - 'Unable to load cache', stack_info=True) + logging.warning("Unable to load cache", stack_info=True) - cache_version = meta_json.get('version', 0) + cache_version = meta_json.get("version", 0) if cache_version < 1: - logging.info('Migrating cache to version 1.') - cover_art_re = re.compile(r'(\d+)_(\d+)') - abs_path = self.calculate_abs_path('cover_art/') + logging.info("Migrating cache to version 1.") + cover_art_re = re.compile(r"(\d+)_(\d+)") + abs_path = self.calculate_abs_path("cover_art/") abs_path.mkdir(parents=True, exist_ok=True) for cover_art_file in abs_path.iterdir(): match = cover_art_re.match(cover_art_file.name) if match: art_id, dimensions = map(int, match.groups()) if dimensions == 1000: - no_dimens = cover_art_file.parent.joinpath( - '{art_id}') - logging.debug( - f'Moving {cover_art_file} to {no_dimens}') + no_dimens = cover_art_file.parent.joinpath("{art_id}") + logging.debug(f"Moving {cover_art_file} to {no_dimens}") shutil.move(cover_art_file, no_dimens) else: - logging.debug(f'Deleting {cover_art_file}') + logging.debug(f"Deleting {cover_art_file}") cover_art_file.unlink() - self.cache['version'] = 1 + self.cache["version"] = 1 cache_configs = [ # Playlists - ('playlists', Playlist, list), - ('playlist_details', PlaylistWithSongs, dict), - ('genres', Genre, list), - ('song_details', Child, dict), - + ("playlists", Playlist, list), + ("playlist_details", PlaylistWithSongs, dict), + ("genres", Genre, list), + ("song_details", Child, dict), # Non-ID3 caches - ('music_directories', Directory, dict), - ('indexes', Artist, list), - + ("music_directories", Directory, dict), + ("indexes", Artist, list), # ID3 caches - ('albums', AlbumWithSongsID3, 'dict-list'), - ('album_details', AlbumWithSongsID3, dict), - ('artists', ArtistID3, list), - ('artist_details', ArtistWithAlbumsID3, dict), - ('artist_infos', ArtistInfo2, dict), + ("albums", AlbumWithSongsID3, "dict-list"), + ("album_details", AlbumWithSongsID3, dict), + ("artists", ArtistID3, list), + ("artist_details", ArtistWithAlbumsID3, dict), + ("artist_infos", ArtistInfo2, dict), ] for name, type_name, default in cache_configs: if default == list: self.cache[name] = [ - type_name.from_json(x) - for x in meta_json.get(name) or [] + type_name.from_json(x) for x in meta_json.get(name) or [] ] elif default == dict: self.cache[name] = { id: type_name.from_json(x) for id, x in (meta_json.get(name) or {}).items() } - elif default == 'dict-list': + elif default == "dict-list": self.cache[name] = { n: [type_name.from_json(x) for x in xs] for n, xs in (meta_json.get(name) or {}).items() @@ -432,46 +428,43 @@ class CacheManager(metaclass=Singleton): def save_cache_info(self): os.makedirs(self.app_config.cache_location, exist_ok=True) - cache_meta_file = self.calculate_abs_path('.cache_meta') + cache_meta_file = self.calculate_abs_path(".cache_meta") os.makedirs(os.path.dirname(cache_meta_file), exist_ok=True) - with open(cache_meta_file, 'w+') as f, self.cache_lock: - f.write( - json.dumps( - self.cache, indent=2, cls=CacheManager.CacheEncoder)) + with open(cache_meta_file, "w+") as f, self.cache_lock: + f.write(json.dumps(self.cache, indent=2, cls=CacheManager.CacheEncoder)) def save_file(self, absolute_path: Path, data: bytes): # Make the necessary directories and write to file. os.makedirs(absolute_path.parent, exist_ok=True) - with open(absolute_path, 'wb+') as f: + with open(absolute_path, "wb+") as f: f.write(data) def calculate_abs_path(self, *relative_paths) -> Path: assert self.app_config.server is not None return Path(self.app_config.cache_location).joinpath( - self.app_config.server.strhash(), *relative_paths) + self.app_config.server.strhash(), *relative_paths + ) def calculate_download_path(self, *relative_paths) -> Path: """ Determine where to temporarily put the file as it is downloading. """ assert self.app_config.server is not None - xdg_cache_home = ( - os.environ.get('XDG_CACHE_HOME') - or os.path.expanduser('~/.cache')) + xdg_cache_home = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser( + "~/.cache" + ) return Path(xdg_cache_home).joinpath( - 'sublime-music', - self.app_config.server.strhash(), - *relative_paths, + "sublime-music", self.app_config.server.strhash(), *relative_paths, ) def return_cached_or_download( - self, - relative_path: Union[Path, str], - download_fn: Callable[[], bytes], - before_download: Callable[[], None] = lambda: None, - force: bool = False, - allow_download: bool = True, - ) -> 'CacheManager.Result[str]': + self, + relative_path: Union[Path, str], + download_fn: Callable[[], bytes], + before_download: Callable[[], None] = lambda: None, + force: bool = False, + allow_download: bool = True, + ) -> "CacheManager.Result[str]": abs_path = self.calculate_abs_path(relative_path) abs_path_str = str(abs_path) download_path = self.calculate_download_path(relative_path) @@ -480,7 +473,7 @@ class CacheManager(metaclass=Singleton): return CacheManager.Result.from_data(abs_path_str) if not allow_download: - return CacheManager.Result.from_data('') + return CacheManager.Result.from_data("") def do_download() -> str: resource_downloading = False @@ -491,14 +484,14 @@ class CacheManager(metaclass=Singleton): self.current_downloads.add(abs_path_str) if resource_downloading: - logging.info(f'{abs_path} already being downloaded.') + logging.info(f"{abs_path} already being downloaded.") # The resource is already being downloaded. Busy loop until # it has completed. Then, just return the path to the # resource. while abs_path_str in self.current_downloads: sleep(0.2) else: - logging.info(f'{abs_path} not found. Downloading...') + logging.info(f"{abs_path} not found. Downloading...") os.makedirs(download_path.parent, exist_ok=True) try: @@ -512,7 +505,7 @@ class CacheManager(metaclass=Singleton): if download_path.exists(): shutil.move(str(download_path), abs_path) - logging.info(f'{abs_path} downloaded. Returning.') + logging.info(f"{abs_path} downloaded. Returning.") return abs_path_str def after_download(path: str): @@ -531,7 +524,7 @@ class CacheManager(metaclass=Singleton): return CacheManager.executor.submit(fn, *args) def delete_cached_cover_art(self, id: int): - relative_path = f'cover_art/*{id}*' + relative_path = f"cover_art/*{id}*" abs_path = self.calculate_abs_path(relative_path) @@ -542,14 +535,13 @@ class CacheManager(metaclass=Singleton): self, before_download: Callable[[], None] = lambda: None, force: bool = False, - ) -> 'CacheManager.Result[List[Playlist]]': - if self.cache.get('playlists') and not force: - return CacheManager.Result.from_data( - self.cache['playlists'] or []) + ) -> "CacheManager.Result[List[Playlist]]": + if self.cache.get("playlists") and not force: + return CacheManager.Result.from_data(self.cache["playlists"] or []) def after_download(playlists: List[Playlist]): with self.cache_lock: - self.cache['playlists'] = playlists + self.cache["playlists"] = playlists self.save_cache_info() return CacheManager.Result.from_server( @@ -559,11 +551,11 @@ class CacheManager(metaclass=Singleton): ) def invalidate_playlists_cache(self): - if not self.cache.get('playlists'): + if not self.cache.get("playlists"): return with self.cache_lock: - self.cache['playlists'] = [] + self.cache["playlists"] = [] self.save_cache_info() def get_playlist( @@ -571,19 +563,18 @@ class CacheManager(metaclass=Singleton): playlist_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, - ) -> 'CacheManager.Result[PlaylistWithSongs]': - playlist_details = self.cache.get('playlist_details', {}) + ) -> "CacheManager.Result[PlaylistWithSongs]": + playlist_details = self.cache.get("playlist_details", {}) if playlist_id in playlist_details and not force: - return CacheManager.Result.from_data( - playlist_details[playlist_id]) + return CacheManager.Result.from_data(playlist_details[playlist_id]) def after_download(playlist: PlaylistWithSongs): with self.cache_lock: - self.cache['playlist_details'][playlist_id] = playlist + self.cache["playlist_details"][playlist_id] = playlist # Playlists have the song details, so save those too. - for song in (playlist.entry or []): - self.cache['song_details'][song.id] = song + for song in playlist.entry or []: + self.cache["song_details"][song.id] = song self.save_cache_info() @@ -608,7 +599,7 @@ class CacheManager(metaclass=Singleton): def do_update_playlist(): self.server.update_playlist(playlist_id, *args, **kwargs) with self.cache_lock: - del self.cache['playlist_details'][playlist_id] + del self.cache["playlist_details"][playlist_id] return CacheManager.create_future(do_update_playlist) @@ -616,8 +607,8 @@ class CacheManager(metaclass=Singleton): self, before_download: Callable[[], None] = lambda: None, force: bool = False, - ) -> 'CacheManager.Result[List[ArtistID3]]': - cache_name = 'artists' + ) -> "CacheManager.Result[List[ArtistID3]]": + cache_name = "artists" if self.cache.get(cache_name) and not force: return CacheManager.Result.from_data(self.cache[cache_name]) @@ -644,12 +635,11 @@ class CacheManager(metaclass=Singleton): artist_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, - ) -> 'CacheManager.Result[ArtistWithAlbumsID3]': - cache_name = 'artist_details' + ) -> "CacheManager.Result[ArtistWithAlbumsID3]": + cache_name = "artist_details" if artist_id in self.cache.get(cache_name, {}) and not force: - return CacheManager.Result.from_data( - self.cache[cache_name][artist_id]) + return CacheManager.Result.from_data(self.cache[cache_name][artist_id]) def after_download(artist: ArtistWithAlbumsID3): with self.cache_lock: @@ -663,11 +653,11 @@ class CacheManager(metaclass=Singleton): ) def get_indexes( - self, - before_download: Callable[[], None] = lambda: None, - force: bool = False, - ) -> 'CacheManager.Result[List[Artist]]': - cache_name = 'indexes' + self, + before_download: Callable[[], None] = lambda: None, + force: bool = False, + ) -> "CacheManager.Result[List[Artist]]": + cache_name = "indexes" if self.cache.get(cache_name) and not force: return CacheManager.Result.from_data(self.cache[cache_name]) @@ -690,16 +680,15 @@ class CacheManager(metaclass=Singleton): ) def get_music_directory( - self, - id: int, - before_download: Callable[[], None] = lambda: None, - force: bool = False, - ) -> 'CacheManager.Result[Directory]': - cache_name = 'music_directories' + self, + id: int, + before_download: Callable[[], None] = lambda: None, + force: bool = False, + ) -> "CacheManager.Result[Directory]": + cache_name = "music_directories" if id in self.cache.get(cache_name, {}) and not force: - return CacheManager.Result.from_data( - self.cache[cache_name][id]) + return CacheManager.Result.from_data(self.cache[cache_name][id]) def after_download(directory: Directory): with self.cache_lock: @@ -713,16 +702,15 @@ class CacheManager(metaclass=Singleton): ) def get_artist_info( - self, - artist_id: int, - before_download: Callable[[], None] = lambda: None, - force: bool = False, - ) -> 'CacheManager.Result[ArtistInfo2]': - cache_name = 'artist_infos' + self, + artist_id: int, + before_download: Callable[[], None] = lambda: None, + force: bool = False, + ) -> "CacheManager.Result[ArtistInfo2]": + cache_name = "artist_infos" if artist_id in self.cache.get(cache_name, {}) and not force: - return CacheManager.Result.from_data( - self.cache[cache_name][artist_id]) + return CacheManager.Result.from_data(self.cache[cache_name][artist_id]) def after_download(artist_info: ArtistInfo2): if not artist_info: @@ -733,59 +721,63 @@ class CacheManager(metaclass=Singleton): self.save_cache_info() return CacheManager.Result.from_server( - lambda: - (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()), + lambda: (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()), before_download=before_download, after_download=after_download, ) def get_artist_artwork( - self, - artist: Union[Artist, ArtistID3], - before_download: Callable[[], None] = lambda: None, - force: bool = False, - ) -> 'CacheManager.Result[str]': + self, + artist: Union[Artist, ArtistID3], + before_download: Callable[[], None] = lambda: None, + force: bool = False, + ) -> "CacheManager.Result[str]": def do_get_artist_artwork( - artist_info: ArtistInfo2) -> 'CacheManager.Result[str]': - lastfm_url = ''.join(artist_info.largeImageUrl or []) + artist_info: ArtistInfo2, + ) -> "CacheManager.Result[str]": + lastfm_url = "".join(artist_info.largeImageUrl or []) - is_placeholder = lastfm_url == '' + is_placeholder = lastfm_url == "" is_placeholder |= lastfm_url.endswith( - '2a96cbd8b46e442fc41c2b86b821562f.png') + "2a96cbd8b46e442fc41c2b86b821562f.png" + ) is_placeholder |= lastfm_url.endswith( - '1024px-No_image_available.svg.png') + "1024px-No_image_available.svg.png" + ) # If it is the placeholder LastFM image, try and use the cover # art filename given by the server. if is_placeholder: if isinstance(artist, (ArtistWithAlbumsID3, ArtistID3)): if artist.coverArt: + return CacheManager.get_cover_art_filename(artist.coverArt) + elif ( + isinstance(artist, ArtistWithAlbumsID3) + and artist.album + and len(artist.album) > 0 + ): return CacheManager.get_cover_art_filename( - artist.coverArt) - elif (isinstance(artist, ArtistWithAlbumsID3) - and artist.album and len(artist.album) > 0): - return CacheManager.get_cover_art_filename( - artist.album[0].coverArt) + artist.album[0].coverArt + ) - elif (isinstance(artist, Directory) - and len(artist.child) > 0): + elif isinstance(artist, Directory) and len(artist.child) > 0: # Retrieve the first album's cover art return CacheManager.get_cover_art_filename( - artist.child[0].coverArt) + artist.child[0].coverArt + ) - if lastfm_url == '': - return CacheManager.Result.from_data('') + if lastfm_url == "": + return CacheManager.Result.from_data("") - url_hash = hashlib.md5(lastfm_url.encode('utf-8')).hexdigest() + url_hash = hashlib.md5(lastfm_url.encode("utf-8")).hexdigest() return self.return_cached_or_download( - f'cover_art/artist.{url_hash}', + f"cover_art/artist.{url_hash}", lambda: requests.get(lastfm_url).content, before_download=before_download, force=force, ) - def download_fn( - artist_info: CacheManager.Result[ArtistInfo2]) -> str: + def download_fn(artist_info: CacheManager.Result[ArtistInfo2]) -> str: # In this case, artist_info is a future, so we have to wait for # its result before calculating. Then, immediately unwrap the # result() because we are already within a future. @@ -794,8 +786,7 @@ class CacheManager(metaclass=Singleton): artist_info = CacheManager.get_artist_info(artist.id) if artist_info.is_future: return CacheManager.Result.from_server( - lambda: download_fn(artist_info), - before_download=before_download, + lambda: download_fn(artist_info), before_download=before_download, ) else: return do_get_artist_artwork(artist_info.result()) @@ -807,27 +798,22 @@ class CacheManager(metaclass=Singleton): force: bool = False, # Look at documentation for get_album_list in server.py: **params, - ) -> 'CacheManager.Result[List[AlbumID3]]': - cache_name = 'albums' + ) -> "CacheManager.Result[List[AlbumID3]]": + cache_name = "albums" - if (len(self.cache.get(cache_name, {}).get(type_, [])) > 0 - and not force): - return CacheManager.Result.from_data( - self.cache[cache_name][type_]) + if len(self.cache.get(cache_name, {}).get(type_, [])) > 0 and not force: + return CacheManager.Result.from_data(self.cache[cache_name][type_]) def do_get_album_list() -> List[AlbumID3]: - def get_page( - offset: int, - page_size: int = 500, - ) -> List[AlbumID3]: - return self.server.get_album_list2( - type_, - size=page_size, - offset=offset, - **params, - ).album or [] + def get_page(offset: int, page_size: int = 500,) -> List[AlbumID3]: + return ( + self.server.get_album_list2( + type_, size=page_size, offset=offset, **params, + ).album + or [] + ) - page_size = 40 if type_ == 'random' else 500 + page_size = 40 if type_ == "random" else 500 offset = 0 next_page = get_page(offset, page_size=page_size) @@ -859,20 +845,19 @@ class CacheManager(metaclass=Singleton): album_id: int, before_download: Callable[[], None] = lambda: None, force: bool = False, - ) -> 'CacheManager.Result[AlbumWithSongsID3]': - cache_name = 'album_details' + ) -> "CacheManager.Result[AlbumWithSongsID3]": + cache_name = "album_details" if album_id in self.cache.get(cache_name, {}) and not force: - return CacheManager.Result.from_data( - self.cache[cache_name][album_id]) + return CacheManager.Result.from_data(self.cache[cache_name][album_id]) def after_download(album: AlbumWithSongsID3): with self.cache_lock: self.cache[cache_name][album_id] = album # Albums have the song details as well, so save those too. - for song in album.get('song', []): - self.cache['song_details'][song.id] = song + for song in album.get("song", []): + self.cache["song_details"][song.id] = song self.save_cache_info() return CacheManager.Result.from_server( @@ -882,9 +867,7 @@ class CacheManager(metaclass=Singleton): ) def batch_delete_cached_songs( - self, - song_ids: List[int], - on_song_delete: Callable[[], None], + self, song_ids: List[int], on_song_delete: Callable[[], None], ) -> Future: def do_delete_cached_songs(): # Do the actual download. @@ -899,10 +882,10 @@ class CacheManager(metaclass=Singleton): return CacheManager.create_future(do_delete_cached_songs) def batch_download_songs( - self, - song_ids: List[int], - before_download: Callable[[], None], - on_song_download_complete: Callable[[], None], + self, + song_ids: List[int], + before_download: Callable[[], None], + on_song_download_complete: Callable[[], None], ) -> Future: def do_download_song(song_id: int): try: @@ -941,18 +924,19 @@ class CacheManager(metaclass=Singleton): return CacheManager.create_future(do_batch_download_songs) def get_cover_art_filename( - self, - id: str, - before_download: Callable[[], None] = lambda: None, - force: bool = False, - allow_download: bool = True, - ) -> 'CacheManager.Result[str]': + self, + id: str, + before_download: Callable[[], None] = lambda: None, + force: bool = False, + allow_download: bool = True, + ) -> "CacheManager.Result[str]": if id is None: - default_art_path = 'ui/images/default-album-art.png' + default_art_path = "ui/images/default-album-art.png" return CacheManager.Result.from_data( - str(Path(__file__).parent.joinpath(default_art_path))) + str(Path(__file__).parent.joinpath(default_art_path)) + ) return self.return_cached_or_download( - f'cover_art/{id}', + f"cover_art/{id}", lambda: self.server.get_cover_art(id), before_download=before_download, force=force, @@ -960,15 +944,14 @@ class CacheManager(metaclass=Singleton): ) def get_song_details( - self, - song_id: int, - before_download: Callable[[], None] = lambda: None, - force: bool = False, - ) -> 'CacheManager.Result[Child]': - cache_name = 'song_details' + self, + song_id: int, + before_download: Callable[[], None] = lambda: None, + force: bool = False, + ) -> "CacheManager.Result[Child]": + cache_name = "song_details" if self.cache[cache_name].get(song_id) and not force: - return CacheManager.Result.from_data( - self.cache[cache_name][song_id]) + return CacheManager.Result.from_data(self.cache[cache_name][song_id]) def after_download(song_details: Child): with self.cache_lock: @@ -985,13 +968,11 @@ class CacheManager(metaclass=Singleton): return CacheManager.create_future(self.server.get_play_queue) def save_play_queue( - self, - play_queue: List[str], - current: str, - position: float, + self, play_queue: List[str], current: str, position: float, ): CacheManager.create_future( - self.server.save_play_queue, play_queue, current, position) + self.server.save_play_queue, play_queue, current, position + ) def scrobble(self, song_id: int) -> Future: def do_scrobble(): @@ -1000,10 +981,7 @@ class CacheManager(metaclass=Singleton): return CacheManager.create_future(do_scrobble) def get_song_filename_or_stream( - self, - song: Child, - format: str = None, - force_stream: bool = False, + self, song: Child, format: str = None, force_stream: bool = False, ) -> Tuple[str, bool]: abs_path = self.calculate_abs_path(song.path) if abs_path.exists() and not force_stream: @@ -1011,16 +989,16 @@ class CacheManager(metaclass=Singleton): return (self.server.get_stream_url(song.id, format=format), True) def get_genres( - self, - before_download: Callable[[], None] = lambda: None, - force: bool = False, - ) -> 'CacheManager.Result[List[Genre]]': - if self.cache.get('genres') and not force: - return CacheManager.Result.from_data(self.cache['genres']) + self, + before_download: Callable[[], None] = lambda: None, + force: bool = False, + ) -> "CacheManager.Result[List[Genre]]": + if self.cache.get("genres") and not force: + return CacheManager.Result.from_data(self.cache["genres"]) def after_download(genres: List[Genre]): with self.cache_lock: - self.cache['genres'] = genres + self.cache["genres"] = genres self.save_cache_info() return CacheManager.Result.from_server( @@ -1034,9 +1012,9 @@ class CacheManager(metaclass=Singleton): query: str, search_callback: Callable[[SearchResult, bool], None], before_download: Callable[[], None] = lambda: None, - ) -> 'CacheManager.Result': - if query == '': - search_callback(SearchResult(''), True) + ) -> "CacheManager.Result": + if query == "": + search_callback(SearchResult(""), True) return CacheManager.Result.from_data(None) before_download() @@ -1058,11 +1036,11 @@ class CacheManager(metaclass=Singleton): # Local Results search_result = SearchResult(query) search_result.add_results( - 'album', itertools.chain(*self.cache['albums'].values())) - search_result.add_results('artist', self.cache['artists']) - search_result.add_results( - 'song', self.cache['song_details'].values()) - search_result.add_results('playlist', self.cache['playlists']) + "album", itertools.chain(*self.cache["albums"].values()) + ) + search_result.add_results("artist", self.cache["artists"]) + search_result.add_results("song", self.cache["song_details"].values()) + search_result.add_results("playlist", self.cache["playlists"]) search_callback(search_result, False) # Wait longer to see if the user types anything else so we @@ -1078,9 +1056,9 @@ class CacheManager(metaclass=Singleton): # SearchResult. If it fails, that's fine, we will use the # finally to always return a final SearchResult to the UI. server_result = search_fn(query) - search_result.add_results('album', server_result.album) - search_result.add_results('artist', server_result.artist) - search_result.add_results('song', server_result.song) + search_result.add_results("album", server_result.album) + search_result.add_results("artist", server_result.artist) + search_result.add_results("song", server_result.song) except Exception: # We really don't care about what the exception was (could # be connection error, could be invalid JSON, etc.) because @@ -1095,8 +1073,7 @@ class CacheManager(metaclass=Singleton): nonlocal cancelled cancelled = True - return CacheManager.Result.from_server( - do_search, on_cancel=on_cancel) + return CacheManager.Result.from_server(do_search, on_cancel=on_cancel) def get_cached_status(self, song: Child) -> SongCacheStatus: cache_path = self.calculate_abs_path(song.path) @@ -1113,10 +1090,9 @@ class CacheManager(metaclass=Singleton): _instance: Optional[__CacheManagerInternal] = None def __init__(self): - raise Exception('Do not instantiate the CacheManager.') + raise Exception("Do not instantiate the CacheManager.") @staticmethod def reset(app_config: AppConfiguration): - CacheManager._instance = CacheManager.__CacheManagerInternal( - app_config) + CacheManager._instance = CacheManager.__CacheManagerInternal(app_config) similarity_ratio.cache_clear() diff --git a/sublime/config.py b/sublime/config.py index ebfe680..59554b1 100644 --- a/sublime/config.py +++ b/sublime/config.py @@ -26,26 +26,26 @@ class ReplayGainType(Enum): ALBUM = 2 def as_string(self) -> str: - return ['no', 'track', 'album'][self.value] + return ["no", "track", "album"][self.value] @staticmethod - def from_string(replay_gain_type: str) -> 'ReplayGainType': + def from_string(replay_gain_type: str) -> "ReplayGainType": return { - 'no': ReplayGainType.NO, - 'disabled': ReplayGainType.NO, - 'track': ReplayGainType.TRACK, - 'album': ReplayGainType.ALBUM, + "no": ReplayGainType.NO, + "disabled": ReplayGainType.NO, + "track": ReplayGainType.TRACK, + "album": ReplayGainType.ALBUM, }[replay_gain_type.lower()] @dataclass(unsafe_hash=True) class ServerConfiguration: - name: str = 'Default' - server_address: str = 'http://yourhost' - local_network_address: str = '' - local_network_ssid: str = '' - username: str = '' - password: str = '' + name: str = "Default" + server_address: str = "http://yourhost" + local_network_address: str = "" + local_network_ssid: str = "" + username: str = "" + password: str = "" sync_enabled: bool = True disable_cert_verify: bool = False version: int = 0 @@ -70,14 +70,14 @@ class ServerConfiguration: '6df23dc03f9b54cc38a0fc1483df6e21' """ server_info = self.name + self.server_address + self.username - return hashlib.md5(server_info.encode('utf-8')).hexdigest() + return hashlib.md5(server_info.encode("utf-8")).hexdigest() @dataclass class AppConfiguration: servers: List[ServerConfiguration] = field(default_factory=list) current_server_index: int = -1 - cache_location: str = '' + cache_location: str = "" max_cache_size_mb: int = -1 # -1 means unlimited always_stream: bool = False # always stream instead of downloading songs download_on_stream: bool = True # also download when streaming a song @@ -91,10 +91,10 @@ class AppConfiguration: filename: Optional[Path] = None @staticmethod - def load_from_file(filename: Path) -> 'AppConfiguration': + def load_from_file(filename: Path) -> "AppConfiguration": args = {} if filename.exists(): - with open(filename, 'r') as f: + with open(filename, "r") as f: field_names = {f.name for f in fields(AppConfiguration)} args = yaml.load(f, Loader=yaml.CLoader).items() args = dict(filter(lambda kv: kv[0] in field_names, args)) @@ -107,13 +107,12 @@ class AppConfiguration: def __post_init__(self): # Default the cache_location to ~/.local/share/sublime-music if not self.cache_location: - path = Path(os.environ.get('XDG_DATA_HOME') or '~/.local/share') - path = path.expanduser().joinpath('sublime-music').resolve() + path = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share") + path = path.expanduser().joinpath("sublime-music").resolve() self.cache_location = path.as_posix() # Deserialize the YAML into the ServerConfiguration object. - if (len(self.servers) > 0 - and type(self.servers[0]) != ServerConfiguration): + if len(self.servers) > 0 and type(self.servers[0]) != ServerConfiguration: self.servers = [ServerConfiguration(**sc) for sc in self.servers] self._state = None @@ -152,17 +151,17 @@ class AppConfiguration: self._current_server_hash = self.server.strhash() if self.state_file_location.exists(): try: - with open(self.state_file_location, 'rb') as f: + with open(self.state_file_location, "rb") as f: self._state = UIState(**pickle.load(f)) except Exception: - logging.warning( - f"Couldn't load state from {self.state_file_location}") + logging.warning(f"Couldn't load state from {self.state_file_location}") # Just ignore any errors, it is only UI state. self._state = UIState() # Do the import in the function to avoid circular imports. from sublime.cache_manager import CacheManager from sublime.adapters import AdapterManager + CacheManager.reset(self) AdapterManager.reset(self) @@ -171,18 +170,18 @@ class AppConfiguration: assert self.server is not None server_hash = self.server.strhash() - state_file_location = Path( - os.environ.get('XDG_DATA_HOME') or '~/.local/share') + state_file_location = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share") return state_file_location.expanduser().joinpath( - 'sublime-music', server_hash, 'state.pickle') + "sublime-music", server_hash, "state.pickle" + ) def save(self): # Save the config as YAML. self.filename.parent.mkdir(parents=True, exist_ok=True) - with open(self.filename, 'w+') as f: + with open(self.filename, "w+") as f: f.write(yaml.dump(asdict(self))) # Save the state for the current server. self.state_file_location.parent.mkdir(parents=True, exist_ok=True) - with open(self.state_file_location, 'wb+') as f: + with open(self.state_file_location, "wb+") as f: pickle.dump(asdict(self.state), f) diff --git a/sublime/dbus/__init__.py b/sublime/dbus/__init__.py index ceb6278..efc1f62 100644 --- a/sublime/dbus/__init__.py +++ b/sublime/dbus/__init__.py @@ -1,3 +1,3 @@ -from .manager import DBusManager, dbus_propagate +from .manager import dbus_propagate, DBusManager -__all__ = ('DBusManager', 'dbus_propagate') +__all__ = ("dbus_propagate", "DBusManager") diff --git a/sublime/dbus/manager.py b/sublime/dbus/manager.py index 1202152..65294a5 100644 --- a/sublime/dbus/manager.py +++ b/sublime/dbus/manager.py @@ -8,6 +8,7 @@ from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple from deepdiff import DeepDiff from gi.repository import Gio, GLib +from sublime.adapters import AdapterManager from sublime.cache_manager import CacheManager from sublime.config import AppConfiguration from sublime.players import Player @@ -15,9 +16,8 @@ from sublime.ui.state import RepeatType def dbus_propagate(param_self: Any = None) -> Callable: - """ - Wraps a function which causes changes to DBus properties. - """ + """Wraps a function which causes changes to DBus properties.""" + def decorator(function: Callable) -> Callable: @functools.wraps(function) def wrapper(*args): @@ -38,19 +38,22 @@ class DBusManager: def __init__( self, connection: Gio.DBusConnection, - do_on_method_call: Callable[[ - Gio.DBusConnection, - str, - str, - str, - str, - GLib.Variant, - Gio.DBusMethodInvocation, - ], None], + do_on_method_call: Callable[ + [ + Gio.DBusConnection, + str, + str, + str, + str, + GLib.Variant, + Gio.DBusMethodInvocation, + ], + None, + ], on_set_property: Callable[ - [Gio.DBusConnection, str, str, str, str, GLib.Variant], None], - get_config_and_player: Callable[[], Tuple[AppConfiguration, - Optional[Player]]], + [Gio.DBusConnection, str, str, str, str, GLib.Variant], None + ], + get_config_and_player: Callable[[], Tuple[AppConfiguration, Optional[Player]]], ): self.get_config_and_player = get_config_and_player self.do_on_method_call = do_on_method_call @@ -59,21 +62,20 @@ class DBusManager: def dbus_name_acquired(connection: Gio.DBusConnection, name: str): specs = [ - 'org.mpris.MediaPlayer2.xml', - 'org.mpris.MediaPlayer2.Player.xml', - 'org.mpris.MediaPlayer2.Playlists.xml', - 'org.mpris.MediaPlayer2.TrackList.xml', + "org.mpris.MediaPlayer2.xml", + "org.mpris.MediaPlayer2.Player.xml", + "org.mpris.MediaPlayer2.Playlists.xml", + "org.mpris.MediaPlayer2.TrackList.xml", ] for spec in specs: spec_path = os.path.join( - os.path.dirname(__file__), - f'mpris_specs/{spec}', + os.path.dirname(__file__), f"mpris_specs/{spec}", ) with open(spec_path) as f: node_info = Gio.DBusNodeInfo.new_for_xml(f.read()) connection.register_object( - '/org/mpris/MediaPlayer2', + "/org/mpris/MediaPlayer2", node_info.interfaces[0], self.on_method_call, self.on_get_property, @@ -86,7 +88,7 @@ class DBusManager: self.bus_number = Gio.bus_own_name_on_connection( connection, - 'org.mpris.MediaPlayer2.sublimemusic', + "org.mpris.MediaPlayer2.sublimemusic", Gio.BusNameOwnerFlags.NONE, dbus_name_acquired, dbus_name_lost, @@ -96,12 +98,12 @@ class DBusManager: Gio.bus_unown_name(self.bus_number) def on_get_property( - self, - connection: Gio.DBusConnection, - sender: str, - path: str, - interface: str, - property_name: str, + self, + connection: Gio.DBusConnection, + sender: str, + path: str, + interface: str, + property_name: str, ) -> GLib.Variant: value = self.property_dict().get(interface, {}).get(property_name) return DBusManager.to_variant(value) @@ -120,31 +122,23 @@ class DBusManager: return # TODO (#127): I don't even know if this works. - if interface == 'org.freedesktop.DBus.Properties': - if method == 'Get': + if interface == "org.freedesktop.DBus.Properties": + if method == "Get": invocation.return_value( - self.on_get_property( - connection, sender, path, interface, *params)) - elif method == 'Set': - self.on_set_property( - connection, sender, path, interface, *params) - elif method == 'GetAll': + self.on_get_property(connection, sender, path, interface, *params) + ) + elif method == "Set": + self.on_set_property(connection, sender, path, interface, *params) + elif method == "GetAll": all_properties = { k: DBusManager.to_variant(v) for k, v in self.property_dict()[interface].items() } - invocation.return_value( - GLib.Variant('(a{sv})', (all_properties, ))) + invocation.return_value(GLib.Variant("(a{sv})", (all_properties,))) return self.do_on_method_call( - connection, - sender, - path, - interface, - method, - params, - invocation, + connection, sender, path, interface, method, params, invocation, ) @staticmethod @@ -160,18 +154,12 @@ class DBusManager: if type(value) == dict: return GLib.Variant( - 'a{sv}', - {k: DBusManager.to_variant(v) - for k, v in value.items()}, + "a{sv}", {k: DBusManager.to_variant(v) for k, v in value.items()}, ) - variant_type = { - list: 'as', - str: 's', - int: 'i', - float: 'd', - bool: 'b', - }.get(type(value)) + variant_type = {list: "as", str: "s", int: "i", float: "d", bool: "b"}.get( + type(value) + ) if not variant_type: return value return GLib.Variant(variant_type, value) @@ -183,113 +171,97 @@ class DBusManager: state = config.state has_current_song = state.current_song is not None has_next_song = False - if state.repeat_type in (RepeatType.REPEAT_QUEUE, - RepeatType.REPEAT_SONG): + if state.repeat_type in (RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG): has_next_song = True elif has_current_song: - has_next_song = ( - state.current_song_index < len(state.play_queue) - 1) + has_next_song = state.current_song_index < len(state.play_queue) - 1 if state.active_playlist_id is None: - active_playlist = (False, GLib.Variant('(oss)', ('/', '', ''))) + active_playlist = (False, GLib.Variant("(oss)", ("/", "", ""))) else: - playlist_result = CacheManager.get_playlist( - state.active_playlist_id) + playlist_result = AdapterManager.get_playlist_details( + state.active_playlist_id + ) - if playlist_result.is_future: - # If we have to wait for the playlist result, just return - # no playlist. - active_playlist = (False, GLib.Variant('(oss)', ('/', '', ''))) - else: + if playlist_result.data_is_available: playlist = playlist_result.result() active_playlist = ( True, GLib.Variant( - '(oss)', + "(oss)", ( - '/playlist/' + playlist.id, + "/playlist/" + playlist.id, playlist.name, - CacheManager.get_cover_art_url(playlist.coverArt), + CacheManager.get_cover_art_url(playlist.cover_art), ), ), ) + else: + # If we have to wait for the playlist result, just return + # no playlist. + active_playlist = (False, GLib.Variant("(oss)", ("/", "", ""))) - get_playlists_result = CacheManager.get_playlists() - if get_playlists_result.is_future: - playlist_count = 0 - else: + get_playlists_result = AdapterManager.get_playlists() + if get_playlists_result.data_is_available: playlist_count = len(get_playlists_result.result()) + else: + playlist_count = 0 return { - 'org.mpris.MediaPlayer2': { - 'CanQuit': True, - 'CanRaise': True, - 'HasTrackList': True, - 'Identity': 'Sublime Music', - 'DesktopEntry': 'sublime-music', - 'SupportedUriSchemes': [], - 'SupportedMimeTypes': [], + "org.mpris.MediaPlayer2": { + "CanQuit": True, + "CanRaise": True, + "HasTrackList": True, + "Identity": "Sublime Music", + "DesktopEntry": "sublime-music", + "SupportedUriSchemes": [], + "SupportedMimeTypes": [], }, - 'org.mpris.MediaPlayer2.Player': { - 'PlaybackStatus': { - (False, False): 'Stopped', - (False, True): 'Stopped', - (True, False): 'Paused', - (True, True): 'Playing', + "org.mpris.MediaPlayer2.Player": { + "PlaybackStatus": { + (False, False): "Stopped", + (False, True): "Stopped", + (True, False): "Paused", + (True, True): "Playing", }[player is not None and player.song_loaded, state.playing], - 'LoopStatus': - state.repeat_type.as_mpris_loop_status(), - 'Rate': - 1.0, - 'Shuffle': - state.shuffle_on, - 'Metadata': - self.get_mpris_metadata( - state.current_song_index, - state.play_queue, - ) if state.current_song else {}, - 'Volume': - 0.0 if state.is_muted else state.volume / 100, - 'Position': ( - 'x', + "LoopStatus": state.repeat_type.as_mpris_loop_status(), + "Rate": 1.0, + "Shuffle": state.shuffle_on, + "Metadata": self.get_mpris_metadata( + state.current_song_index, state.play_queue, + ) + if state.current_song + else {}, + "Volume": 0.0 if state.is_muted else state.volume / 100, + "Position": ( + "x", int( max(state.song_progress or 0, 0) - * self.second_microsecond_conversion), + * self.second_microsecond_conversion + ), ), - 'MinimumRate': - 1.0, - 'MaximumRate': - 1.0, - 'CanGoNext': - has_current_song and has_next_song, - 'CanGoPrevious': - has_current_song, - 'CanPlay': - True, - 'CanPause': - True, - 'CanSeek': - True, - 'CanControl': - True, + "MinimumRate": 1.0, + "MaximumRate": 1.0, + "CanGoNext": has_current_song and has_next_song, + "CanGoPrevious": has_current_song, + "CanPlay": True, + "CanPause": True, + "CanSeek": True, + "CanControl": True, }, - 'org.mpris.MediaPlayer2.TrackList': { - 'Tracks': self.get_dbus_playlist(state.play_queue), - 'CanEditTracks': False, + "org.mpris.MediaPlayer2.TrackList": { + "Tracks": self.get_dbus_playlist(state.play_queue), + "CanEditTracks": False, }, - 'org.mpris.MediaPlayer2.Playlists': { - 'PlaylistCount': playlist_count, - 'Orderings': ['Alphabetical', 'Created', 'Modified'], - 'ActivePlaylist': ('(b(oss))', active_playlist), + "org.mpris.MediaPlayer2.Playlists": { + "PlaylistCount": playlist_count, + "Orderings": ["Alphabetical", "Created", "Modified"], + "ActivePlaylist": ("(b(oss))", active_playlist), }, } - def get_mpris_metadata( - self, - idx: int, - play_queue: List[str], - ) -> Dict[str, Any]: + def get_mpris_metadata(self, idx: int, play_queue: List[str],) -> Dict[str, Any]: song_result = CacheManager.get_song_details(play_queue[idx]) if song_result.is_future: return {} @@ -297,18 +269,18 @@ class DBusManager: trackid = self.get_dbus_playlist(play_queue)[idx] duration = ( - 'x', + "x", (song.duration or 0) * self.second_microsecond_conversion, ) return { - 'mpris:trackid': trackid, - 'mpris:length': duration, - 'mpris:artUrl': CacheManager.get_cover_art_url(song.coverArt), - 'xesam:album': song.album or '', - 'xesam:albumArtist': [song.artist or ''], - 'xesam:artist': [song.artist or ''], - 'xesam:title': song.title, + "mpris:trackid": trackid, + "mpris:length": duration, + "mpris:artUrl": CacheManager.get_cover_art_url(song.coverArt), + "xesam:album": song.album or "", + "xesam:albumArtist": [song.artist or ""], + "xesam:artist": [song.artist or ""], + "xesam:title": song.title, } def get_dbus_playlist(self, play_queue: List[str]) -> List[str]: @@ -316,7 +288,7 @@ class DBusManager: tracks = [] for song_id in play_queue: id_ = seen_counts[song_id] - tracks.append(f'/song/{song_id}/{id_}') + tracks.append(f"/song/{song_id}/{id_}") seen_counts[song_id] += 1 return tracks @@ -329,35 +301,36 @@ class DBusManager: changes = defaultdict(dict) - for path, change in diff.get('values_changed', {}).items(): + for path, change in diff.get("values_changed", {}).items(): interface, property_name = self.diff_parse_re.match(path).groups() - changes[interface][property_name] = change['new_value'] + changes[interface][property_name] = change["new_value"] - if diff.get('dictionary_item_added'): + if diff.get("dictionary_item_added"): changes = new_property_dict for interface, changed_props in changes.items(): # If the metadata has changed, just make the entire Metadata object # part of the update. - if 'Metadata' in changed_props.keys(): - changed_props['Metadata'] = new_property_dict[interface][ - 'Metadata'] + if "Metadata" in changed_props.keys(): + changed_props["Metadata"] = new_property_dict[interface]["Metadata"] # Special handling for when the position changes (a seek). # Technically, I'm sending this signal too often, but I don't think # it really matters. - if (interface == 'org.mpris.MediaPlayer2.Player' - and 'Position' in changed_props): + if ( + interface == "org.mpris.MediaPlayer2.Player" + and "Position" in changed_props + ): self.connection.emit_signal( None, - '/org/mpris/MediaPlayer2', + "/org/mpris/MediaPlayer2", interface, - 'Seeked', - GLib.Variant('(x)', (changed_props['Position'][1], )), + "Seeked", + GLib.Variant("(x)", (changed_props["Position"][1],)), ) # Do not emit the property change. - del changed_props['Position'] + del changed_props["Position"] # Special handling for when the track list changes. # Technically, I'm supposed to use `TrackAdded` and `TrackRemoved` @@ -370,35 +343,39 @@ class DBusManager: # # So I think that any change is invasive enough that I should use # this signal. - if (interface == 'org.mpris.MediaPlayer2.TrackList' - and 'Tracks' in changed_props): - track_list = changed_props['Tracks'] + if ( + interface == "org.mpris.MediaPlayer2.TrackList" + and "Tracks" in changed_props + ): + track_list = changed_props["Tracks"] if len(track_list) > 0: - current_track = ( - new_property_dict['org.mpris.MediaPlayer2.Player'] - ['Metadata'].get('mpris:trackid', track_list[0])) + current_track = new_property_dict["org.mpris.MediaPlayer2.Player"][ + "Metadata" + ].get("mpris:trackid", track_list[0]) self.connection.emit_signal( None, - '/org/mpris/MediaPlayer2', + "/org/mpris/MediaPlayer2", interface, - 'TrackListReplaced', - GLib.Variant('(aoo)', (track_list, current_track)), + "TrackListReplaced", + GLib.Variant("(aoo)", (track_list, current_track)), ) self.connection.emit_signal( None, - '/org/mpris/MediaPlayer2', - 'org.freedesktop.DBus.Properties', - 'PropertiesChanged', + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", GLib.Variant( - '(sa{sv}as)', ( + "(sa{sv}as)", + ( interface, { k: DBusManager.to_variant(v) for k, v in changed_props.items() }, [], - )), + ), + ), ) # Update state for next diff. diff --git a/sublime/from_json.py b/sublime/from_json.py index a034a54..ce0716a 100644 --- a/sublime/from_json.py +++ b/sublime/from_json.py @@ -8,13 +8,11 @@ from dateutil import parser def from_json(template_type: Any, data: Any) -> Any: """ - Converts data from a JSON parse into an instantiation of the Python object - specified by template_type. + Converts data from a JSON parse into an instantiation of the Python object specified + by template_type. - Arguments: - - template_type: the template type to deserialize into - data: the data to deserialize to the class + :param template_type: the template type to deserialize into + :param data: the data to deserialize to the class """ # Approach for deserialization here: # https://stackoverflow.com/a/40639688/2319844 @@ -24,8 +22,7 @@ def from_json(template_type: Any, data: Any) -> Any: if isinstance(template_type, typing.ForwardRef): # type: ignore template_type = template_type._evaluate(globals(), locals()) - annotations: Dict[str, - Type] = getattr(template_type, '__annotations__', {}) + annotations: Dict[str, Type] = getattr(template_type, "__annotations__", {}) # Handle primitive of objects instance: Any = None @@ -33,7 +30,7 @@ def from_json(template_type: Any, data: Any) -> Any: instance = None # Handle generics. List[*], Dict[*, *] in particular. elif type(template_type) == typing._GenericAlias: # type: ignore - if getattr(template_type, '__origin__') == typing.Union: + if getattr(template_type, "__origin__") == typing.Union: template_type = template_type.__args__[0] instance = from_json(template_type, data) else: @@ -42,11 +39,11 @@ def from_json(template_type: Any, data: Any) -> Any: # This is not very elegant since it doesn't allow things which # sublass from List or Dict. For my purposes, this doesn't matter. - if class_name == 'List': + if class_name == "List": inner_type = template_type.__args__[0] instance = [from_json(inner_type, value) for value in data] - elif class_name == 'Dict': + elif class_name == "Dict": key_type, val_type = template_type.__args__ instance = { from_json(key_type, key): from_json(val_type, value) @@ -54,8 +51,10 @@ def from_json(template_type: Any, data: Any) -> Any: } else: raise Exception( - 'Trying to deserialize an unsupported type: {}'.format( - template_type._name)) + "Trying to deserialize an unsupported type: {}".format( + template_type._name + ) + ) elif template_type == str or issubclass(template_type, str): instance = data elif template_type == int or issubclass(template_type, int): @@ -64,7 +63,7 @@ def from_json(template_type: Any, data: Any) -> Any: instance = bool(data) elif type(template_type) == EnumMeta: if type(data) == dict: - instance = template_type(data.get('_value_')) + instance = template_type(data.get("_value_")) else: instance = template_type(data) elif template_type == datetime: @@ -83,6 +82,7 @@ def from_json(template_type: Any, data: Any) -> Any: **{ field: from_json(field_type, data.get(field)) for field, field_type in annotations.items() - }) + } + ) return instance diff --git a/sublime/players.py b/sublime/players.py index dc294dc..e6317b1 100644 --- a/sublime/players.py +++ b/sublime/players.py @@ -74,52 +74,58 @@ class Player: self._set_is_muted(value) def reset(self): - raise NotImplementedError( - 'reset must be implemented by implementor of Player') + raise NotImplementedError("reset must be implemented by implementor of Player") def play_media(self, file_or_url: str, progress: float, song: Child): raise NotImplementedError( - 'play_media must be implemented by implementor of Player') + "play_media must be implemented by implementor of Player" + ) def _is_playing(self): raise NotImplementedError( - '_is_playing must be implemented by implementor of Player') + "_is_playing must be implemented by implementor of Player" + ) def pause(self): - raise NotImplementedError( - 'pause must be implemented by implementor of Player') + raise NotImplementedError("pause must be implemented by implementor of Player") def toggle_play(self): raise NotImplementedError( - 'toggle_play must be implemented by implementor of Player') + "toggle_play must be implemented by implementor of Player" + ) def seek(self, value: float): - raise NotImplementedError( - 'seek must be implemented by implementor of Player') + raise NotImplementedError("seek must be implemented by implementor of Player") def _get_timepos(self): raise NotImplementedError( - 'get_timepos must be implemented by implementor of Player') + "get_timepos must be implemented by implementor of Player" + ) def _get_volume(self): raise NotImplementedError( - '_get_volume must be implemented by implementor of Player') + "_get_volume must be implemented by implementor of Player" + ) def _set_volume(self, value: float): raise NotImplementedError( - '_set_volume must be implemented by implementor of Player') + "_set_volume must be implemented by implementor of Player" + ) def _get_is_muted(self): raise NotImplementedError( - '_get_is_muted must be implemented by implementor of Player') + "_get_is_muted must be implemented by implementor of Player" + ) def _set_is_muted(self, value: bool): raise NotImplementedError( - '_set_is_muted must be implemented by implementor of Player') + "_set_is_muted must be implemented by implementor of Player" + ) def shutdown(self): raise NotImplementedError( - 'shutdown must be implemented by implementor of Player') + "shutdown must be implemented by implementor of Player" + ) class MPVPlayer(Player): @@ -130,19 +136,18 @@ class MPVPlayer(Player): on_player_event: Callable[[PlayerEvent], None], config: AppConfiguration, ): - super().__init__( - on_timepos_change, on_track_end, on_player_event, config) + super().__init__(on_timepos_change, on_track_end, on_player_event, config) self.mpv = mpv.MPV() - self.mpv.audio_client_name = 'sublime-music' + self.mpv.audio_client_name = "sublime-music" self.mpv.replaygain = config.replay_gain.as_string() self.progress_value_lock = threading.Lock() self.progress_value_count = 0 self._muted = False - self._volume = 100. + self._volume = 100.0 self._can_hotswap_source = True - @self.mpv.property_observer('time-pos') + @self.mpv.property_observer("time-pos") def time_observer(_: Any, value: Optional[float]): self.on_timepos_change(value) if value is None and self.progress_value_count > 1: @@ -169,10 +174,7 @@ class MPVPlayer(Player): self.mpv.pause = False self.mpv.command( - 'loadfile', - file_or_url, - 'replace', - f'start={progress}' if progress else '', + "loadfile", file_or_url, "replace", f"start={progress}" if progress else "", ) self._song_loaded = True @@ -180,10 +182,10 @@ class MPVPlayer(Player): self.mpv.pause = True def toggle_play(self): - self.mpv.cycle('pause') + self.mpv.cycle("pause") def seek(self, value: float): - self.mpv.seek(str(value), 'absolute') + self.mpv.seek(str(value), "absolute") def _get_volume(self) -> float: return self._volume @@ -236,31 +238,30 @@ class ChromecastPlayer(Player): self.app = bottle.Bottle() - @self.app.route('/') + @self.app.route("/") def index() -> str: - return ''' + return """

Sublime Music Local Music Server

Sublime Music uses this port as a server for serving music Chromecasts on the same LAN.

- ''' + """ - @self.app.route('/s/') + @self.app.route("/s/") def stream_song(token: str) -> bytes: if token != self.token: - raise bottle.HTTPError(status=401, body='Invalid token.') + raise bottle.HTTPError(status=401, body="Invalid token.") song = CacheManager.get_song_details(self.song_id).result() filename, _ = CacheManager.get_song_filename_or_stream(song) - with open(filename, 'rb') as fin: + with open(filename, "rb") as fin: song_buffer = io.BytesIO(fin.read()) bottle.response.set_header( - 'Content-Type', - mimetypes.guess_type(filename)[0], + "Content-Type", mimetypes.guess_type(filename)[0], ) - bottle.response.set_header('Accept-Ranges', 'bytes') + bottle.response.set_header("Accept-Ranges", "bytes") return song_buffer.read() def set_song_and_token(self, song_id: str, token: str): @@ -276,11 +277,11 @@ class ChromecastPlayer(Player): def get_chromecasts(cls) -> Future: def do_get_chromecasts() -> List[pychromecast.Chromecast]: if not ChromecastPlayer.getting_chromecasts: - logging.info('Getting Chromecasts') + logging.info("Getting Chromecasts") ChromecastPlayer.getting_chromecasts = True ChromecastPlayer.chromecasts = pychromecast.get_chromecasts() else: - logging.info('Already getting Chromecasts... busy wait') + logging.info("Already getting Chromecasts... busy wait") while ChromecastPlayer.getting_chromecasts: sleep(0.1) @@ -291,15 +292,15 @@ class ChromecastPlayer(Player): def set_playing_chromecast(self, uuid: str): self.chromecast = next( - cc for cc in ChromecastPlayer.chromecasts - if cc.device.uuid == UUID(uuid)) + cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid) + ) self.chromecast.media_controller.register_status_listener( - ChromecastPlayer.media_status_listener) - self.chromecast.register_status_listener( - ChromecastPlayer.cast_status_listener) + ChromecastPlayer.media_status_listener + ) + self.chromecast.register_status_listener(ChromecastPlayer.cast_status_listener) self.chromecast.wait() - logging.info(f'Using: {self.chromecast.device.friendly_name}') + logging.info(f"Using: {self.chromecast.device.friendly_name}") def __init__( self, @@ -308,16 +309,17 @@ class ChromecastPlayer(Player): on_player_event: Callable[[PlayerEvent], None], config: AppConfiguration, ): - super().__init__( - on_timepos_change, on_track_end, on_player_event, config) + super().__init__(on_timepos_change, on_track_end, on_player_event, config) self._timepos = 0.0 self.time_incrementor_running = False self._can_hotswap_source = False ChromecastPlayer.cast_status_listener.on_new_cast_status = ( - self.on_new_cast_status) + self.on_new_cast_status + ) ChromecastPlayer.media_status_listener.on_new_media_status = ( - self.on_new_media_status) + self.on_new_media_status + ) # Set host_ip # TODO (#128): should have a mechanism to update this. Maybe it should @@ -326,7 +328,7 @@ class ChromecastPlayer(Player): # piped over the VPN tunnel. try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(('8.8.8.8', 80)) + s.connect(("8.8.8.8", 80)) self.host_ip = s.getsockname()[0] s.close() except OSError: @@ -336,42 +338,43 @@ class ChromecastPlayer(Player): self.serve_over_lan = config.serve_over_lan if self.serve_over_lan: - self.server_thread = ChromecastPlayer.ServerThread( - '0.0.0.0', self.port) + self.server_thread = ChromecastPlayer.ServerThread("0.0.0.0", self.port) self.server_thread.start() def on_new_cast_status( - self, - status: pychromecast.socket_client.CastStatus, + self, status: pychromecast.socket_client.CastStatus, ): self.on_player_event( PlayerEvent( - 'volume_change', + "volume_change", status.volume_level * 100 if not status.volume_muted else 0, - )) + ) + ) # This normally happens when "Stop Casting" is pressed in the Google # Home app. if status.session_id is None: - self.on_player_event(PlayerEvent('play_state_change', False)) + self.on_player_event(PlayerEvent("play_state_change", False)) self._song_loaded = False def on_new_media_status( - self, - status: pychromecast.controllers.media.MediaStatus, + self, status: pychromecast.controllers.media.MediaStatus, ): # Detect the end of a track and go to the next one. - if (status.idle_reason == 'FINISHED' and status.player_state == 'IDLE' - and self._timepos > 0): + if ( + status.idle_reason == "FINISHED" + and status.player_state == "IDLE" + and self._timepos > 0 + ): self.on_track_end() self._timepos = status.current_time self.on_player_event( PlayerEvent( - 'play_state_change', - status.player_state in ('PLAYING', 'BUFFERING'), - )) + "play_state_change", status.player_state in ("PLAYING", "BUFFERING"), + ) + ) # Start the time incrementor just in case this was a play notification. self.start_time_incrementor() @@ -400,8 +403,7 @@ class ChromecastPlayer(Player): if self.playing: break if url is not None: - if (url == self.chromecast.media_controller.status - .content_id): + if url == self.chromecast.media_controller.status.content_id: break callback() @@ -421,30 +423,29 @@ class ChromecastPlayer(Player): # If it's a local file, then see if we can serve it over the LAN. if not stream_scheme: if self.serve_over_lan: - token = base64.b64encode(os.urandom(64)).decode('ascii') - for r in (('+', '.'), ('/', '-'), ('=', '_')): + token = base64.b64encode(os.urandom(64)).decode("ascii") + for r in (("+", "."), ("/", "-"), ("=", "_")): token = token.replace(*r) self.server_thread.set_song_and_token(song.id, token) - file_or_url = f'http://{self.host_ip}:{self.port}/s/{token}' + file_or_url = f"http://{self.host_ip}:{self.port}/s/{token}" else: file_or_url, _ = CacheManager.get_song_filename_or_stream( - song, - force_stream=True, + song, force_stream=True, ) cover_art_url = CacheManager.get_cover_art_url(song.coverArt) self.chromecast.media_controller.play_media( file_or_url, # Just pretend that whatever we send it is mp3, even if it isn't. - 'audio/mp3', + "audio/mp3", current_time=progress, title=song.title, thumb=cover_art_url, metadata={ - 'metadataType': 3, - 'albumName': song.album, - 'artist': song.artist, - 'trackNumber': song.track, + "metadataType": 3, + "albumName": song.album, + "artist": song.artist, + "trackNumber": song.track, }, ) self._timepos = progress diff --git a/sublime/server/__init__.py b/sublime/server/__init__.py index 956bb4c..2089886 100644 --- a/sublime/server/__init__.py +++ b/sublime/server/__init__.py @@ -3,4 +3,4 @@ This module defines a stateless server which interops with the Subsonic API. """ from .server import Server -__all__ = ('Server', ) +__all__ = ("Server",) diff --git a/sublime/server/api_object.py b/sublime/server/api_object.py index ff0d797..bebdb91 100644 --- a/sublime/server/api_object.py +++ b/sublime/server/api_object.py @@ -6,15 +6,15 @@ from sublime.from_json import from_json as _from_json class APIObject: """Defines the base class for objects coming from the Subsonic API.""" + @classmethod def from_json(cls, data: Dict[str, Any]) -> Any: """ - Creates an :class:`APIObject` by taking the ``data`` and passing it to - the class constructor and then recursively calling ``from_json`` on all - of the fields. ``data`` just has to be a well-formed :class:`dict`, so - it can come from the JSON or XML APIs. + Creates an :class:`APIObject` by taking the ``data`` and passing it to the class + constructor and then recursively calling ``from_json`` on all of the fields. + ``data`` just has to be a well-formed :class:`dict`, so it can come from the + JSON or XML APIs. - :param data: a Python dictionary representation of the data to - deserialize + :param data: a Python dictionary representation of the data to deserialize """ return _from_json(cls, data) diff --git a/sublime/server/api_objects.py b/sublime/server/api_objects.py index efea773..a32ec4b 100644 --- a/sublime/server/api_objects.py +++ b/sublime/server/api_objects.py @@ -33,10 +33,10 @@ class AverageRating(APIObject, float): class MediaType(APIObject, Enum): - MUSIC = 'music' - PODCAST = 'podcast' - AUDIOBOOK = 'audiobook' - VIDEO = 'video' + MUSIC = "music" + PODCAST = "podcast" + AUDIOBOOK = "audiobook" + VIDEO = "video" def get(self, key: str, default: Any = None) -> Any: return getattr(self, key, default) @@ -451,12 +451,12 @@ class MusicFolders(APIObject): class PodcastStatus(APIObject, Enum): - NEW = 'new' - DOWNLOADING = 'downloading' - COMPLETED = 'completed' - ERROR = 'error' - DELETED = 'deleted' - SKIPPED = 'skipped' + NEW = "new" + DOWNLOADING = "downloading" + COMPLETED = "completed" + ERROR = "error" + DELETED = "deleted" + SKIPPED = "skipped" def get(self, key: str, default: Any = None) -> Any: return getattr(self, key, default) @@ -656,8 +656,8 @@ class Podcasts(APIObject): class ResponseStatus(APIObject, Enum): - OK = 'ok' - FAILED = 'failed' + OK = "ok" + FAILED = "failed" def get(self, key: str, default: Any = None) -> Any: return getattr(self, key, default) diff --git a/sublime/server/server.py b/sublime/server/server.py index 044c773..0c89c8b 100644 --- a/sublime/server/server.py +++ b/sublime/server/server.py @@ -61,9 +61,10 @@ class Server: * The ``server`` module is stateless. The only thing that it does is allow the module's user to query the \\*sonic server via the API. """ + class SubsonicServerError(Exception): - def __init__(self: 'Server.SubsonicServerError', error: Error): - super().__init__(f'{error.code}: {error.message}') + def __init__(self: "Server.SubsonicServerError", error: Error): + super().__init__(f"{error.code}: {error.message}") def __init__( self, @@ -82,26 +83,27 @@ class Server: def _get_params(self) -> Dict[str, str]: """See Subsonic API Introduction for details.""" return { - 'u': self.username, - 'p': self.password, - 'c': 'Sublime Music', - 'f': 'json', - 'v': '1.15.0', + "u": self.username, + "p": self.password, + "c": "Sublime Music", + "f": "json", + "v": "1.15.0", } def _make_url(self, endpoint: str) -> str: - return f'{self.hostname}/rest/{endpoint}.view' + return f"{self.hostname}/rest/{endpoint}.view" # def _get(self, url, timeout=(3.05, 2), **params): def _get(self, url: str, **params) -> Any: params = {**self._get_params(), **params} - logging.info(f'[START] get: {url}') + logging.info(f"[START] get: {url}") - if os.environ.get('SUBLIME_MUSIC_DEBUG_DELAY'): + if os.environ.get("SUBLIME_MUSIC_DEBUG_DELAY"): logging.info( "SUBLIME_MUSIC_DEBUG_DELAY enabled. Pausing for " - f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds.") - sleep(float(os.environ['SUBLIME_MUSIC_DEBUG_DELAY'])) + f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds." + ) + sleep(float(os.environ["SUBLIME_MUSIC_DEBUG_DELAY"])) # Deal with datetime parameters (convert to milliseconds since 1970) for k, v in params.items(): @@ -116,15 +118,13 @@ class Server: ) # TODO (#122): make better if result.status_code != 200: - raise Exception(f'[FAIL] get: {url} status={result.status_code}') + raise Exception(f"[FAIL] get: {url} status={result.status_code}") - logging.info(f'[FINISH] get: {url}') + logging.info(f"[FINISH] get: {url}") return result def _get_json( - self, - url: str, - **params: Union[None, str, datetime, int, List[int]], + self, url: str, **params: Union[None, str, datetime, int, List[int]], ) -> Response: """ Make a get request to a *Sonic REST API. Handle all types of errors @@ -135,18 +135,18 @@ class Server: :raises Exception: needs some work TODO """ result = self._get(url, **params) - subsonic_response = result.json().get('subsonic-response') + subsonic_response = result.json().get("subsonic-response") # TODO (#122): make better if not subsonic_response: - raise Exception(f'[FAIL] get: invalid JSON from {url}') + raise Exception(f"[FAIL] get: invalid JSON from {url}") - if subsonic_response['status'] == 'failed': + if subsonic_response["status"] == "failed": code, message = ( - subsonic_response['error'].get('code'), - subsonic_response['error'].get('message'), + subsonic_response["error"].get("code"), + subsonic_response["error"].get("message"), ) - raise Exception(f'Subsonic API Error #{code}: {message}') + raise Exception(f"Subsonic API Error #{code}: {message}") response = Response.from_json(subsonic_response) @@ -159,8 +159,8 @@ class Server: def do_download(self, url: str, **params) -> bytes: download = self._get(url, **params) if not download: - raise Exception('Download failed') - if 'json' in download.headers.get('Content-Type'): + raise Exception("Download failed") + if "json" in download.headers.get("Content-Type"): # TODO (#122): make better raise Exception("Didn't expect JSON.") return download.content @@ -169,20 +169,18 @@ class Server: """ Used to test connectivity with the server. """ - return self._get_json(self._make_url('ping')) + return self._get_json(self._make_url("ping")) def get_license(self) -> License: """Get details about the software license.""" - return self._get_json(self._make_url('getLicense')).license + return self._get_json(self._make_url("getLicense")).license def get_music_folders(self) -> MusicFolders: """Returns all configured top-level music folders.""" - return self._get_json(self._make_url('getMusicFolders')).musicFolders + return self._get_json(self._make_url("getMusicFolders")).musicFolders def get_indexes( - self, - music_folder_id: int = None, - if_modified_since: int = None, + self, music_folder_id: int = None, if_modified_since: int = None, ) -> Indexes: """ Returns an indexed structure of all artists. @@ -193,7 +191,7 @@ class Server: artist collection has changed since the given time. """ result = self._get_json( - self._make_url('getIndexes'), + self._make_url("getIndexes"), musicFolderId=music_folder_id, ifModifiedSince=if_modified_since, ) @@ -207,15 +205,12 @@ class Server: :param dir_id: A string which uniquely identifies the music folder. Obtained by calls to ``getIndexes`` or ``getMusicDirectory``. """ - result = self._get_json( - self._make_url('getMusicDirectory'), - id=str(dir_id), - ) + result = self._get_json(self._make_url("getMusicDirectory"), id=str(dir_id),) return result.directory def get_genres(self) -> Genres: """Returns all genres.""" - return self._get_json(self._make_url('getGenres')).genres + return self._get_json(self._make_url("getGenres")).genres def get_artists(self, music_folder_id: int = None) -> ArtistsID3: """ @@ -225,8 +220,7 @@ class Server: folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('getArtists'), - musicFolderId=music_folder_id, + self._make_url("getArtists"), musicFolderId=music_folder_id, ) return result.artists @@ -237,7 +231,7 @@ class Server: :param artist_id: The artist ID. """ - return self._get_json(self._make_url('getArtist'), id=artist_id).artist + return self._get_json(self._make_url("getArtist"), id=artist_id).artist def get_album(self, album_id: int) -> AlbumWithSongsID3: """ @@ -246,7 +240,7 @@ class Server: :param album_id: The album ID. """ - return self._get_json(self._make_url('getAlbum'), id=album_id).album + return self._get_json(self._make_url("getAlbum"), id=album_id).album def get_song(self, song_id: int) -> Child: """ @@ -254,13 +248,13 @@ class Server: :param song_id: The song ID. """ - return self._get_json(self._make_url('getSong'), id=song_id).song + return self._get_json(self._make_url("getSong"), id=song_id).song def get_videos(self) -> Optional[List[Child]]: """ Returns all video files. """ - return self._get_json(self._make_url('getVideos')).videos.video + return self._get_json(self._make_url("getVideos")).videos.video def get_video_info(self, video_id: int) -> Optional[VideoInfo]: """ @@ -269,14 +263,11 @@ class Server: :param video_id: The video ID. """ - result = self._get_json(self._make_url('getVideoInfo'), id=video_id) + result = self._get_json(self._make_url("getVideoInfo"), id=video_id) return result.videoInfo def get_artist_info( - self, - id: int, - count: int = None, - include_not_present: bool = None, + self, id: int, count: int = None, include_not_present: bool = None, ) -> Optional[ArtistInfo]: """ Returns artist info with biography, image URLs and similar artists, @@ -290,7 +281,7 @@ class Server: Spec. """ result = self._get_json( - self._make_url('getArtistInfo'), + self._make_url("getArtistInfo"), id=id, count=count, includeNotPresent=include_not_present, @@ -298,10 +289,7 @@ class Server: return result.artistInfo def get_artist_info2( - self, - id: int, - count: int = None, - include_not_present: bool = None, + self, id: int, count: int = None, include_not_present: bool = None, ) -> Optional[ArtistInfo2]: """ Similar to getArtistInfo, but organizes music according to ID3 tags. @@ -314,7 +302,7 @@ class Server: Spec. """ result = self._get_json( - self._make_url('getArtistInfo2'), + self._make_url("getArtistInfo2"), id=id, count=count, includeNotPresent=include_not_present, @@ -327,7 +315,7 @@ class Server: :param id: The album or song ID. """ - result = self._get_json(self._make_url('getAlbumInfo'), id=id) + result = self._get_json(self._make_url("getAlbumInfo"), id=id) return result.albumInfo def get_album_info2(self, id: int) -> Optional[AlbumInfo]: @@ -336,7 +324,7 @@ class Server: :param id: The album or song ID. """ - result = self._get_json(self._make_url('getAlbumInfo2'), id=id) + result = self._get_json(self._make_url("getAlbumInfo2"), id=id) return result.albumInfo def get_similar_songs(self, id: int, count: int = None) -> List[Child]: @@ -349,11 +337,7 @@ class Server: :param count: Max number of songs to return. Defaults to 50 according to API Spec. """ - result = self._get_json( - self._make_url('getSimilarSongs'), - id=id, - count=count, - ) + result = self._get_json(self._make_url("getSimilarSongs"), id=id, count=count,) return result.similarSongs.song def get_similar_songs2(self, id: int, count: int = None) -> List[Child]: @@ -364,11 +348,7 @@ class Server: :param count: Max number of songs to return. Defaults to 50 according to API Spec. """ - result = self._get_json( - self._make_url('getSimilarSongs2'), - id=id, - count=count, - ) + result = self._get_json(self._make_url("getSimilarSongs2"), id=id, count=count,) return result.similarSongs2.song def get_top_songs(self, artist: str, count: int = None) -> List[Child]: @@ -380,21 +360,19 @@ class Server: to API Spec. """ result = self._get_json( - self._make_url('getTopSongs'), - artist=artist, - count=count, + self._make_url("getTopSongs"), artist=artist, count=count, ) return result.topSongs.song def get_album_list( - self, - type: str, - size: int = None, - offset: int = None, - from_year: int = None, - to_year: int = None, - genre: str = None, - music_folder_id: int = None, + self, + type: str, + size: int = None, + offset: int = None, + from_year: int = None, + to_year: int = None, + genre: str = None, + music_folder_id: int = None, ) -> AlbumList: """ Returns a list of random, newest, highest rated etc. albums. Similar to @@ -422,7 +400,7 @@ class Server: folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('getAlbumList'), + self._make_url("getAlbumList"), type=type, size=size, offset=offset, @@ -434,14 +412,14 @@ class Server: return result.albumList def get_album_list2( - self, - type: str, - size: int = None, - offset: int = None, - from_year: int = None, - to_year: int = None, - genre: str = None, - music_folder_id: int = None, + self, + type: str, + size: int = None, + offset: int = None, + from_year: int = None, + to_year: int = None, + genre: str = None, + music_folder_id: int = None, ) -> AlbumList2: """ Similar to getAlbumList, but organizes music according to ID3 tags. @@ -467,7 +445,7 @@ class Server: folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('getAlbumList2'), + self._make_url("getAlbumList2"), type=type, size=size, offset=offset, @@ -479,12 +457,12 @@ class Server: return result.albumList2 def get_random_songs( - self, - size: int = None, - genre: str = None, - from_year: str = None, - to_year: str = None, - music_folder_id: int = None, + self, + size: int = None, + genre: str = None, + from_year: str = None, + to_year: str = None, + music_folder_id: int = None, ) -> Songs: """ Returns random songs matching the given criteria. @@ -498,7 +476,7 @@ class Server: given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('getRandomSongs'), + self._make_url("getRandomSongs"), size=size, genre=genre, fromYear=from_year, @@ -508,11 +486,11 @@ class Server: return result.randomSongs def get_songs_by_genre( - self, - genre: str, - count: int = None, - offset: int = None, - music_folder_id: int = None, + self, + genre: str, + count: int = None, + offset: int = None, + music_folder_id: int = None, ) -> Songs: """ Returns songs in a given genre. @@ -526,7 +504,7 @@ class Server: folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('getSongsByGenre'), + self._make_url("getSongsByGenre"), genre=genre, count=count, offset=offset, @@ -539,7 +517,7 @@ class Server: Returns what is currently being played by all users. Takes no extra parameters. """ - return self._get_json(self._make_url('getNowPlaying')).nowPlaying + return self._get_json(self._make_url("getNowPlaying")).nowPlaying def get_starred(self, music_folder_id: int = None) -> Starred: """ @@ -549,8 +527,7 @@ class Server: music folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('getStarred'), - musicFolderId=music_folder_id, + self._make_url("getStarred"), musicFolderId=music_folder_id, ) return result.starred @@ -562,21 +539,20 @@ class Server: music folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('getStarred2'), - musicFolderId=music_folder_id, + self._make_url("getStarred2"), musicFolderId=music_folder_id, ) return result.starred2 - @deprecated(version='1.4.0', reason='You should use search2 instead.') + @deprecated(version="1.4.0", reason="You should use search2 instead.") def search( - self, - artist: str = None, - album: str = None, - title: str = None, - any: str = None, - count: int = None, - offset: int = None, - newer_than: datetime = None, + self, + artist: str = None, + album: str = None, + title: str = None, + any: str = None, + count: int = None, + offset: int = None, + newer_than: datetime = None, ) -> SearchResult: """ Returns a listing of files matching the given search criteria. Supports @@ -591,28 +567,27 @@ class Server: :param newer_than: Only return matches that are newer than this. """ result = self._get_json( - self._make_url('search'), + self._make_url("search"), artist=artist, album=album, title=title, any=any, count=count, offset=offset, - newerThan=math.floor(newer_than.timestamp() - * 1000) if newer_than else None, + newerThan=math.floor(newer_than.timestamp() * 1000) if newer_than else None, ) return result.searchResult def search2( - self, - query: str, - artist_count: int = None, - artist_offset: int = None, - album_count: int = None, - album_offset: int = None, - song_count: int = None, - song_offset: int = None, - music_folder_id: int = None, + self, + query: str, + artist_count: int = None, + artist_offset: int = None, + album_count: int = None, + album_offset: int = None, + song_count: int = None, + song_offset: int = None, + music_folder_id: int = None, ) -> SearchResult2: """ Returns albums, artists and songs matching the given search criteria. @@ -635,7 +610,7 @@ class Server: music folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('search2'), + self._make_url("search2"), query=query, artistCount=artist_count, artistOffset=artist_offset, @@ -648,15 +623,15 @@ class Server: return result.searchResult2 def search3( - self, - query: str, - artist_count: int = None, - artist_offset: int = None, - album_count: int = None, - album_offset: int = None, - song_count: int = None, - song_offset: int = None, - music_folder_id: int = None, + self, + query: str, + artist_count: int = None, + artist_offset: int = None, + album_count: int = None, + album_offset: int = None, + song_count: int = None, + song_offset: int = None, + music_folder_id: int = None, ) -> SearchResult3: """ Similar to search2, but organizes music according to ID3 tags. @@ -678,7 +653,7 @@ class Server: music folder with the given ID. See ``getMusicFolders``. """ result = self._get_json( - self._make_url('search3'), + self._make_url("search3"), query=query, artistCount=artist_count, artistOffset=artist_offset, @@ -698,10 +673,7 @@ class Server: user rather than for the authenticated user. The authenticated user must have admin role if this parameter is used. """ - result = self._get_json( - self._make_url('getPlaylists'), - username=username, - ) + result = self._get_json(self._make_url("getPlaylists"), username=username,) return result.playlists def get_playlist(self, id: int) -> PlaylistWithSongs: @@ -711,7 +683,7 @@ class Server: :param username: ID of the playlist to return, as obtained by ``getPlaylists``. """ - result = self._get_json(self._make_url('getPlaylist'), id=id) + result = self._get_json(self._make_url("getPlaylist"), id=id) return result.playlist def create_playlist( @@ -730,7 +702,7 @@ class Server: a list of IDs. """ result = self._get_json( - self._make_url('createPlaylist'), + self._make_url("createPlaylist"), playlistId=playlist_id, name=name, songId=song_id, @@ -739,13 +711,13 @@ class Server: return result.playlist or result def update_playlist( - self, - playlist_id: int, - name: str = None, - comment: str = None, - public: bool = None, - song_id_to_add: Union[int, List[int]] = None, - song_index_to_remove: Union[int, List[int]] = None, + self, + playlist_id: int, + name: str = None, + comment: str = None, + public: bool = None, + song_id_to_add: Union[int, List[int]] = None, + song_index_to_remove: Union[int, List[int]] = None, ) -> Response: """ Updates a playlist. Only the owner of a playlist is allowed to update @@ -762,7 +734,7 @@ class Server: the playlist. Can be a single ID or a list of IDs. """ return self._get_json( - self._make_url('updatePlaylist'), + self._make_url("updatePlaylist"), playlistId=playlist_id, name=name, comment=comment, @@ -773,17 +745,17 @@ class Server: def delete_playlist(self, id: int) -> Response: """Deletes a saved playlist.""" - return self._get_json(self._make_url('deletePlaylist'), id=id) + return self._get_json(self._make_url("deletePlaylist"), id=id) def get_stream_url( - self, - id: str, - max_bit_rate: int = None, - format: str = None, - time_offset: int = None, - size: int = None, - estimate_content_length: bool = False, - converted: bool = False, + self, + id: str, + max_bit_rate: int = None, + format: str = None, + time_offset: int = None, + size: int = None, + estimate_content_length: bool = False, + converted: bool = False, ) -> str: """ Gets the URL to stream a given file. @@ -824,7 +796,7 @@ class Server: converted=converted, ) params = {k: v for k, v in params.items() if v} - return self._make_url('stream') + '?' + urlencode(params) + return self._make_url("stream") + "?" + urlencode(params) def download(self, id: str) -> bytes: """ @@ -834,7 +806,7 @@ class Server: :param id: A string which uniquely identifies the file to stream. Obtained by calls to ``getMusicDirectory``. """ - return self.do_download(self._make_url('download'), id=id) + return self.do_download(self._make_url("download"), id=id) def get_cover_art(self, id: str, size: int = 1000) -> bytes: """ @@ -843,11 +815,7 @@ class Server: :param id: The ID of a song, album or artist. :param size: If specified, scale image to this size. """ - return self.do_download( - self._make_url('getCoverArt'), - id=id, - size=size, - ) + return self.do_download(self._make_url("getCoverArt"), id=id, size=size,) def get_cover_art_url(self, id: str, size: int = 1000) -> str: """ @@ -858,7 +826,7 @@ class Server: """ params = dict(**self._get_params(), id=id, size=size) params = {k: v for k, v in params.items() if v} - return self._make_url('getCoverArt') + '?' + urlencode(params) + return self._make_url("getCoverArt") + "?" + urlencode(params) def get_lyrics(self, artist: str = None, title: str = None) -> Lyrics: """ @@ -868,9 +836,7 @@ class Server: :param title: The song title. """ result = self._get_json( - self._make_url('getLyrics'), - artist=artist, - title=title, + self._make_url("getLyrics"), artist=artist, title=title, ) return result.lyrics @@ -880,13 +846,13 @@ class Server: :param username: the user in question. """ - return self.do_download(self._make_url('getAvatar'), username=username) + return self.do_download(self._make_url("getAvatar"), username=username) def star( - self, - id: Union[int, List[int]] = None, - album_id: Union[int, List[int]] = None, - artist_id: Union[int, List[int]] = None, + self, + id: Union[int, List[int]] = None, + album_id: Union[int, List[int]] = None, + artist_id: Union[int, List[int]] = None, ) -> Response: """ Attaches a star to a song, album or artist. @@ -903,17 +869,14 @@ class Server: ID or a list of IDs. """ return self._get_json( - self._make_url('star'), - id=id, - albumId=album_id, - artistId=artist_id, + self._make_url("star"), id=id, albumId=album_id, artistId=artist_id, ) def unstar( - self, - id: Union[int, List[int]] = None, - album_id: Union[int, List[int]] = None, - artist_id: Union[int, List[int]] = None, + self, + id: Union[int, List[int]] = None, + album_id: Union[int, List[int]] = None, + artist_id: Union[int, List[int]] = None, ) -> Response: """ Removes the star from a song, album or artist. @@ -930,10 +893,7 @@ class Server: ID or a list of IDs. """ return self._get_json( - self._make_url('unstar'), - id=id, - albumId=album_id, - artistId=artist_id, + self._make_url("unstar"), id=id, albumId=album_id, artistId=artist_id, ) def set_rating(self, id: int, rating: int) -> Response: @@ -945,17 +905,10 @@ class Server: :param rating: The rating between 1 and 5 (inclusive), or 0 to remove the rating. """ - return self._get_json( - self._make_url('setRating'), - id=id, - rating=rating, - ) + return self._get_json(self._make_url("setRating"), id=id, rating=rating,) def scrobble( - self, - id: int, - time: datetime = None, - submission: bool = True, + self, id: int, time: datetime = None, submission: bool = True, ) -> Response: """ Registers the local playback of one or more media files. Typically used @@ -980,10 +933,7 @@ class Server: notification. """ return self._get_json( - self._make_url('scrobble'), - id=id, - time=time, - submission=submission, + self._make_url("scrobble"), id=id, time=time, submission=submission, ) def get_shares(self) -> Shares: @@ -991,13 +941,13 @@ class Server: Returns information about shared media this user is allowed to manage. Takes no extra parameters. """ - return self._get_json(self._make_url('getShares')).shares + return self._get_json(self._make_url("getShares")).shares def create_share( - self, - id: Union[int, List[int]], - description: str = None, - expires: datetime = None, + self, + id: Union[int, List[int]], + description: str = None, + expires: datetime = None, ) -> Shares: """ Creates a public URL that can be used by anyone to stream music or @@ -1013,7 +963,7 @@ class Server: :param expires: The time at which the share expires. """ result = self._get_json( - self._make_url('createShare'), + self._make_url("createShare"), id=id, description=description, expires=expires, @@ -1021,10 +971,7 @@ class Server: return result.shares def update_share( - self, - id: int, - description: str = None, - expires: datetime = None, + self, id: int, description: str = None, expires: datetime = None, ) -> Response: """ Updates the description and/or expiration date for an existing share. @@ -1035,7 +982,7 @@ class Server: :param expires: The time at which the share expires. """ return self._get_json( - self._make_url('updateShare'), + self._make_url("updateShare"), id=id, description=description, expires=expires, @@ -1047,18 +994,15 @@ class Server: :param id: ID of the share to delete. """ - return self._get_json(self._make_url('deleteShare'), id=id) + return self._get_json(self._make_url("deleteShare"), id=id) def get_internet_radio_stations(self) -> InternetRadioStations: """Returns all internet radio stations.""" - result = self._get_json(self._make_url('getInternetRadioStations')) + result = self._get_json(self._make_url("getInternetRadioStations")) return result.internetRadioStations def create_internet_radio_station( - self, - stream_url: str, - name: str, - homepage_url: str = None, + self, stream_url: str, name: str, homepage_url: str = None, ) -> Response: """ Adds a new internet radio station. Only users with admin privileges are @@ -1069,18 +1013,14 @@ class Server: :param homepage_url: The home page URL for the station. """ return self._get_json( - self._make_url('createInternetRadioStation'), + self._make_url("createInternetRadioStation"), streamUrl=stream_url, name=name, homepageUrl=homepage_url, ) def update_internet_radio_station( - self, - id: int, - stream_url: str, - name: str, - homepage_url: str = None, + self, id: int, stream_url: str, name: str, homepage_url: str = None, ) -> Response: """ Updates an existing internet radio station. Only users with admin @@ -1092,7 +1032,7 @@ class Server: :param homepage_url: The home page URL for the station. """ return self._get_json( - self._make_url('updateInternetRadioStation'), + self._make_url("updateInternetRadioStation"), id=id, streamUrl=stream_url, name=name, @@ -1106,10 +1046,7 @@ class Server: :param id: The ID for the station. """ - return self._get_json( - self._make_url('deleteInternetRadioStation'), - id=id, - ) + return self._get_json(self._make_url("deleteInternetRadioStation"), id=id,) def get_user(self, username: str) -> User: """ @@ -1120,7 +1057,7 @@ class Server: :param username: The name of the user to retrieve. You can only retrieve your own user unless you have admin privileges. """ - result = self._get_json(self._make_url('getUser'), username=username) + result = self._get_json(self._make_url("getUser"), username=username) return result.user def get_users(self) -> Users: @@ -1129,27 +1066,27 @@ class Server: folder access they have. Only users with admin privileges are allowed to call this method. """ - return self._get_json(self._make_url('getUsers')).users + return self._get_json(self._make_url("getUsers")).users def create_user( - self, - username: str, - password: str, - email: str, - ldap_authenticated: bool = False, - admin_role: bool = False, - settings_role: bool = True, - stream_role: bool = True, - jukebox_role: bool = False, - download_role: bool = False, - upload_role: bool = False, - playlist_role: bool = False, - covert_art_role: bool = False, - comment_role: bool = False, - podcast_role: bool = False, - share_role: bool = False, - video_conversion_role: bool = False, - music_folder_id: Union[int, List[int]] = None, + self, + username: str, + password: str, + email: str, + ldap_authenticated: bool = False, + admin_role: bool = False, + settings_role: bool = True, + stream_role: bool = True, + jukebox_role: bool = False, + download_role: bool = False, + upload_role: bool = False, + playlist_role: bool = False, + covert_art_role: bool = False, + comment_role: bool = False, + podcast_role: bool = False, + share_role: bool = False, + video_conversion_role: bool = False, + music_folder_id: Union[int, List[int]] = None, ) -> Response: """ Creates a new Subsonic user. @@ -1183,7 +1120,7 @@ class Server: user is allowed access to. Can be a single ID or a list of IDs. """ return self._get_json( - self._make_url('createUser'), + self._make_url("createUser"), username=username, password=password, email=email, @@ -1204,24 +1141,24 @@ class Server: ) def update_user( - self, - username: str, - password: str = None, - email: str = None, - ldap_authenticated: bool = False, - admin_role: bool = False, - settings_role: bool = True, - stream_role: bool = True, - jukebox_role: bool = False, - download_role: bool = False, - upload_role: bool = False, - playlist_role: bool = False, - covert_art_role: bool = False, - comment_role: bool = False, - podcast_role: bool = False, - share_role: bool = False, - video_conversion_role: bool = False, - music_folder_id: Union[int, List[int]] = None, + self, + username: str, + password: str = None, + email: str = None, + ldap_authenticated: bool = False, + admin_role: bool = False, + settings_role: bool = True, + stream_role: bool = True, + jukebox_role: bool = False, + download_role: bool = False, + upload_role: bool = False, + playlist_role: bool = False, + covert_art_role: bool = False, + comment_role: bool = False, + podcast_role: bool = False, + share_role: bool = False, + video_conversion_role: bool = False, + music_folder_id: Union[int, List[int]] = None, ) -> Response: """ Modifies an existing Subsonic user. @@ -1255,7 +1192,7 @@ class Server: user is allowed access to. Can be a single ID or a list of IDs. """ return self._get_json( - self._make_url('updateUser'), + self._make_url("updateUser"), username=username, password=password, email=email, @@ -1281,7 +1218,7 @@ class Server: :param username: The name of the new user. """ - return self._get_json(self._make_url('deleteUser'), username=username) + return self._get_json(self._make_url("deleteUser"), username=username) def change_password(self, username: str, password: str) -> Response: """ @@ -1293,9 +1230,7 @@ class Server: of hex-encoded. """ return self._get_json( - self._make_url('changePassword'), - username=username, - password=password, + self._make_url("changePassword"), username=username, password=password, ) def get_bookmarks(self) -> Bookmarks: @@ -1303,13 +1238,10 @@ class Server: Returns all bookmarks for this user. A bookmark is a position within a certain media file. """ - return self._get_json(self._make_url('getBookmarks')).bookmarks + return self._get_json(self._make_url("getBookmarks")).bookmarks def create_bookmarks( - self, - id: int, - position: int, - comment: str = None, + self, id: int, position: int, comment: str = None, ) -> Response: """ Creates or updates a bookmark (a position within a media file). @@ -1321,10 +1253,7 @@ class Server: :param comment: A user-defined comment. """ return self._get_json( - self._make_url('createBookmark'), - id=id, - position=position, - comment=comment, + self._make_url("createBookmark"), id=id, position=position, comment=comment, ) def delete_bookmark(self, id: int) -> Response: @@ -1334,7 +1263,7 @@ class Server: :param id: ID of the media file for which to delete the bookmark. Other users' bookmarks are not affected. """ - return self._get_json(self._make_url('deleteBookmark'), id=id) + return self._get_json(self._make_url("deleteBookmark"), id=id) def get_play_queue(self) -> Optional[PlayQueue]: """ @@ -1345,13 +1274,10 @@ class Server: retaining the same play queue (for instance when listening to an audio book). """ - return self._get_json(self._make_url('getPlayQueue')).playQueue + return self._get_json(self._make_url("getPlayQueue")).playQueue def save_play_queue( - self, - id: Union[int, List[int]], - current: int = None, - position: int = None, + self, id: Union[int, List[int]], current: int = None, position: int = None, ) -> Response: """ Saves the state of the play queue for this user. This includes the @@ -1367,10 +1293,7 @@ class Server: playing song. """ return self._get_json( - self._make_url('savePlayQueue'), - id=id, - current=current, - position=position, + self._make_url("savePlayQueue"), id=id, current=current, position=position, ) def get_scan_status(self) -> ScanStatus: @@ -1378,10 +1301,10 @@ class Server: Returns the current status for media library scanning. Takes no extra parameters. """ - return self._get_json(self._make_url('getScanStatus')).scanStatus + return self._get_json(self._make_url("getScanStatus")).scanStatus def start_scan(self) -> ScanStatus: """ Initiates a rescan of the media libraries. Takes no extra parameters. """ - return self._get_json(self._make_url('startScan')).scanStatus + return self._get_json(self._make_url("startScan")).scanStatus diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py index 3818589..7af124f 100644 --- a/sublime/ui/albums.py +++ b/sublime/ui/albums.py @@ -2,7 +2,8 @@ import datetime from typing import Any, Callable, Iterable, Optional, Tuple, Union import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from sublime.cache_manager import CacheManager @@ -16,12 +17,12 @@ Album = Union[Child, AlbumWithSongsID3] class AlbumsPanel(Gtk.Box): __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -37,18 +38,18 @@ class AlbumsPanel(Gtk.Box): actionbar = Gtk.ActionBar() # Sort by - actionbar.add(Gtk.Label(label='Sort')) + actionbar.add(Gtk.Label(label="Sort")) self.sort_type_combo = self.make_combobox( ( - ('random', 'randomly'), - ('byGenre', 'by genre'), - ('newest', 'by most recently added'), - ('highest', 'by highest rated'), - ('frequent', 'by most played'), - ('recent', 'by most recently played'), - ('alphabetical', 'alphabetically'), - ('starred', 'by starred only'), - ('byYear', 'by year'), + ("random", "randomly"), + ("byGenre", "by genre"), + ("newest", "by most recently added"), + ("highest", "by highest rated"), + ("frequent", "by most played"), + ("recent", "by most recently played"), + ("alphabetical", "alphabetically"), + ("starred", "by starred only"), + ("byYear", "by year"), ), self.on_type_combo_changed, ) @@ -56,10 +57,7 @@ class AlbumsPanel(Gtk.Box): # Alphabetically how? self.alphabetical_type_combo = self.make_combobox( - ( - ('name', 'by album name'), - ('artist', 'by artist name'), - ), + (("name", "by album name"), ("artist", "by artist name"),), self.on_alphabetical_type_change, ) actionbar.pack_start(self.alphabetical_type_combo) @@ -70,23 +68,20 @@ class AlbumsPanel(Gtk.Box): next_decade = datetime.datetime.now().year + 10 - self.from_year_label = Gtk.Label(label='from') + self.from_year_label = Gtk.Label(label="from") actionbar.pack_start(self.from_year_label) - self.from_year_spin_button = Gtk.SpinButton.new_with_range( - 0, next_decade, 1) - self.from_year_spin_button.connect( - 'value-changed', self.on_year_changed) + self.from_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1) + self.from_year_spin_button.connect("value-changed", self.on_year_changed) actionbar.pack_start(self.from_year_spin_button) - self.to_year_label = Gtk.Label(label='to') + self.to_year_label = Gtk.Label(label="to") actionbar.pack_start(self.to_year_label) - self.to_year_spin_button = Gtk.SpinButton.new_with_range( - 0, next_decade, 1) - self.to_year_spin_button.connect('value-changed', self.on_year_changed) + self.to_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1) + self.to_year_spin_button.connect("value-changed", self.on_year_changed) actionbar.pack_start(self.to_year_spin_button) - refresh = IconButton('view-refresh-symbolic', 'Refresh list of albums') - refresh.connect('clicked', self.on_refresh_clicked) + refresh = IconButton("view-refresh-symbolic", "Refresh list of albums") + refresh.connect("clicked", self.on_refresh_clicked) actionbar.pack_end(refresh) self.add(actionbar) @@ -94,17 +89,16 @@ class AlbumsPanel(Gtk.Box): scrolled_window = Gtk.ScrolledWindow() self.grid = AlbumsGrid() self.grid.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) - self.grid.connect('cover-clicked', self.on_grid_cover_clicked) + self.grid.connect("cover-clicked", self.on_grid_cover_clicked) scrolled_window.add(self.grid) self.add(scrolled_window) def make_combobox( self, items: Iterable[Tuple[str, str]], - on_change: Callable[['AlbumsPanel', Gtk.ComboBox], None], + on_change: Callable[["AlbumsPanel", Gtk.ComboBox], None], ) -> Gtk.ComboBox: store = Gtk.ListStore(str, str) for item in items: @@ -112,53 +106,46 @@ class AlbumsPanel(Gtk.Box): combo = Gtk.ComboBox.new_with_model(store) combo.set_id_column(0) - combo.connect('changed', on_change) + combo.connect("changed", on_change) renderer_text = Gtk.CellRendererText() combo.pack_start(renderer_text, True) - combo.add_attribute(renderer_text, 'text', 1) + combo.add_attribute(renderer_text, "text", 1) return combo def populate_genre_combo( - self, - app_config: AppConfiguration, - force: bool = False, + self, app_config: AppConfiguration, force: bool = False, ): if not CacheManager.ready(): return def get_genres_done(f: CacheManager.Result): try: - new_store = [ - (genre.value, genre.value) for genre in (f.result() or []) - ] + new_store = [(genre.value, genre.value) for genre in (f.result() or [])] util.diff_song_store(self.genre_combo.get_model(), new_store) current_genre_id = self.get_id(self.genre_combo) if current_genre_id != app_config.state.current_album_genre: - self.genre_combo.set_active_id( - app_config.state.current_album_genre) + self.genre_combo.set_active_id(app_config.state.current_album_genre) finally: self.updating_query = False # Never force. We invalidate the cache ourselves (force is used when # sort params change). genres_future = CacheManager.get_genres(force=False) - genres_future.add_done_callback( - lambda f: GLib.idle_add(get_genres_done, f)) + genres_future.add_done_callback(lambda f: GLib.idle_add(get_genres_done, f)) def update(self, app_config: AppConfiguration, force: bool = False): self.updating_query = True self.sort_type_combo.set_active_id(app_config.state.current_album_sort) self.alphabetical_type_combo.set_active_id( - app_config.state.current_album_alphabetical_sort) - self.from_year_spin_button.set_value( - app_config.state.current_album_from_year) - self.to_year_spin_button.set_value( - app_config.state.current_album_to_year) + app_config.state.current_album_alphabetical_sort + ) + self.from_year_spin_button.set_value(app_config.state.current_album_from_year) + self.to_year_spin_button.set_value(app_config.state.current_album_to_year) self.populate_genre_combo(app_config, force=force) # Show/hide the combo boxes. @@ -169,10 +156,10 @@ class AlbumsPanel(Gtk.Box): else: element.hide() - show_if('alphabetical', self.alphabetical_type_combo) - show_if('byGenre', self.genre_combo) - show_if('byYear', self.from_year_label, self.from_year_spin_button) - show_if('byYear', self.to_year_label, self.to_year_spin_button) + show_if("alphabetical", self.alphabetical_type_combo) + show_if("byGenre", self.genre_combo) + show_if("byYear", self.from_year_label, self.from_year_spin_button) + show_if("byYear", self.to_year_label, self.to_year_spin_button) self.grid.update(self.grid_order_token, app_config, force=force) @@ -183,24 +170,23 @@ class AlbumsPanel(Gtk.Box): return None def on_refresh_clicked(self, button: Any): - self.emit('refresh-window', {}, True) + self.emit("refresh-window", {}, True) def on_type_combo_changed(self, combo: Gtk.ComboBox): new_active_sort = self.get_id(combo) self.grid_order_token = self.grid.update_params(type_=new_active_sort) self.emit_if_not_updating( - 'refresh-window', - {'current_album_sort': new_active_sort}, - False, + "refresh-window", {"current_album_sort": new_active_sort}, False, ) def on_alphabetical_type_change(self, combo: Gtk.ComboBox): new_active_alphabetical_sort = self.get_id(combo) self.grid_order_token = self.grid.update_params( - alphabetical_type=new_active_alphabetical_sort) + alphabetical_type=new_active_alphabetical_sort + ) self.emit_if_not_updating( - 'refresh-window', - {'current_album_alphabetical_sort': new_active_alphabetical_sort}, + "refresh-window", + {"current_album_alphabetical_sort": new_active_alphabetical_sort}, False, ) @@ -208,9 +194,7 @@ class AlbumsPanel(Gtk.Box): new_active_genre = self.get_id(combo) self.grid_order_token = self.grid.update_params(genre=new_active_genre) self.emit_if_not_updating( - 'refresh-window', - {'current_album_genre': new_active_genre}, - True, + "refresh-window", {"current_album_genre": new_active_genre}, True, ) def on_year_changed(self, entry: Gtk.SpinButton) -> bool: @@ -219,25 +203,19 @@ class AlbumsPanel(Gtk.Box): if self.to_year_spin_button == entry: self.grid_order_token = self.grid.update_params(to_year=year) self.emit_if_not_updating( - 'refresh-window', - {'current_album_to_year': year}, - True, + "refresh-window", {"current_album_to_year": year}, True, ) else: self.grid_order_token = self.grid.update_params(from_year=year) self.emit_if_not_updating( - 'refresh-window', - {'current_album_from_year': year}, - True, + "refresh-window", {"current_album_from_year": year}, True, ) return False def on_grid_cover_clicked(self, grid: Any, id: str): self.emit( - 'refresh-window', - {'selected_album_id': id}, - False, + "refresh-window", {"selected_album_id": id}, False, ) def emit_if_not_updating(self, *args): @@ -248,23 +226,20 @@ class AlbumsPanel(Gtk.Box): class AlbumsGrid(Gtk.Overlay): """Defines the albums panel.""" + __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'cover-clicked': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (object, ), - ), + "cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),), } - type_: str = 'random' - alphabetical_type: str = 'name' + type_: str = "random" + alphabetical_type: str = "name" from_year: int = 2010 to_year: int = 2020 - genre: str = 'Rock' + genre: str = "Rock" latest_applied_order_ratchet: int = 0 order_ratchet: int = 0 @@ -283,20 +258,24 @@ class AlbumsGrid(Gtk.Overlay): return self.album.id def __repr__(self) -> str: - return f'' + return f"" def update_params( - self, - type_: str = None, - alphabetical_type: str = None, - from_year: int = None, - to_year: int = None, - genre: str = None, + self, + type_: str = None, + alphabetical_type: str = None, + from_year: int = None, + to_year: int = None, + genre: str = None, ) -> int: # If there's a diff, increase the ratchet. - if (self.type_ != type_ or self.alphabetical_type != alphabetical_type - or self.from_year != from_year or self.to_year != to_year - or self.genre != genre): + if ( + self.type_ != type_ + or self.alphabetical_type != alphabetical_type + or self.from_year != from_year + or self.to_year != to_year + or self.genre != genre + ): self.order_ratchet += 1 self.type_ = type_ or self.type_ self.alphabetical_type = alphabetical_type or self.alphabetical_type @@ -328,8 +307,8 @@ class AlbumsGrid(Gtk.Overlay): halign=Gtk.Align.CENTER, selection_mode=Gtk.SelectionMode.SINGLE, ) - self.grid_top.connect('child-activated', self.on_child_activated) - self.grid_top.connect('size-allocate', self.on_grid_resize) + self.grid_top.connect("child-activated", self.on_child_activated) + self.grid_top.connect("size-allocate", self.on_grid_resize) self.list_store_top = Gio.ListStore() self.grid_top.bind_model(self.list_store_top, self.create_widget) @@ -357,7 +336,7 @@ class AlbumsGrid(Gtk.Overlay): halign=Gtk.Align.CENTER, selection_mode=Gtk.SelectionMode.SINGLE, ) - self.grid_bottom.connect('child-activated', self.on_child_activated) + self.grid_bottom.connect("child-activated", self.on_child_activated) self.list_store_bottom = Gio.ListStore() self.grid_bottom.bind_model(self.list_store_bottom, self.create_widget) @@ -368,7 +347,7 @@ class AlbumsGrid(Gtk.Overlay): self.add(scrolled_window) self.spinner = Gtk.Spinner( - name='grid-spinner', + name="grid-spinner", active=True, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, @@ -376,10 +355,7 @@ class AlbumsGrid(Gtk.Overlay): self.add_overlay(self.spinner) def update( - self, - order_token: int, - app_config: AppConfiguration, - force: bool = False, + self, order_token: int, app_config: AppConfiguration, force: bool = False, ): if order_token < self.latest_applied_order_ratchet: return @@ -397,16 +373,13 @@ class AlbumsGrid(Gtk.Overlay): # Update the detail panel. children = self.detail_box_inner.get_children() - if len(children) > 0 and hasattr(children[0], 'update'): + if len(children) > 0 and hasattr(children[0], "update"): children[0].update(force=force) error_dialog = None def update_grid( - self, - order_token: int, - force: bool = False, - selected_id: str = None, + self, order_token: int, force: bool = False, selected_id: str = None, ): if not CacheManager.ready(): self.spinner.hide() @@ -414,11 +387,8 @@ class AlbumsGrid(Gtk.Overlay): # Calculate the type. type_ = self.type_ - if self.type_ == 'alphabetical': - type_ += { - 'name': 'ByName', - 'artist': 'ByArtist', - }[self.alphabetical_type] + if self.type_ == "alphabetical": + type_ += {"name": "ByName", "artist": "ByArtist"}[self.alphabetical_type] def do_update(f: CacheManager.Result): try: @@ -431,11 +401,12 @@ class AlbumsGrid(Gtk.Overlay): transient_for=self.get_toplevel(), message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, - text='Failed to retrieve albums', + text="Failed to retrieve albums", ) self.error_dialog.format_secondary_markup( - f'Getting albums by {type_} failed due to the following' - f' error\n\n{e}') + f"Getting albums by {type_} failed due to the following" + f" error\n\n{e}" + ) self.error_dialog.run() self.error_dialog.destroy() self.error_dialog = None @@ -447,8 +418,8 @@ class AlbumsGrid(Gtk.Overlay): return should_reload = ( - force - or self.latest_applied_order_ratchet < self.order_ratchet) + force or self.latest_applied_order_ratchet < self.order_ratchet + ) self.latest_applied_order_ratchet = self.order_ratchet self.list_store.remove_all() @@ -484,13 +455,14 @@ class AlbumsGrid(Gtk.Overlay): # ========================================================================= def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget): click_top = flowbox == self.grid_top - selected_index = ( - child.get_index() + (0 if click_top else len(self.list_store_top))) + selected_index = child.get_index() + ( + 0 if click_top else len(self.list_store_top) + ) if click_top and selected_index == self.current_selection: - self.emit('cover-clicked', None) + self.emit("cover-clicked", None) else: - self.emit('cover-clicked', self.list_store[selected_index].id) + self.emit("cover-clicked", self.list_store[selected_index].id) def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle): # TODO (#124): this doesn't work with themes that add extra padding. @@ -500,22 +472,21 @@ class AlbumsGrid(Gtk.Overlay): if new_items_per_row != self.items_per_row: self.items_per_row = min((rect.width // 230), 7) self.detail_box_inner.set_size_request( - self.items_per_row * 230 - 10, - -1, + self.items_per_row * 230 - 10, -1, ) self.reflow_grids() # Helper Methods # ========================================================================= - def create_widget(self, item: 'AlbumsGrid.AlbumModel') -> Gtk.Box: + def create_widget(self, item: "AlbumsGrid.AlbumModel") -> Gtk.Box: widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Cover art image artwork = SpinnerImage( loading=False, - image_name='grid-artwork', - spinner_name='grid-artwork-spinner', + image_name="grid-artwork", + spinner_name="grid-artwork-spinner", image_size=200, ) widget_box.pack_start(artwork, False, False, 0) @@ -532,16 +503,16 @@ class AlbumsGrid(Gtk.Overlay): # Header for the widget header_text = ( - item.album.title - if isinstance(item.album, Child) else item.album.name) + item.album.title if isinstance(item.album, Child) else item.album.name + ) - header_label = make_label(header_text, 'grid-header-label') + header_label = make_label(header_text, "grid-header-label") widget_box.pack_start(header_label, False, False, 0) # Extra info for the widget info_text = util.dot_join(item.album.artist, item.album.year) if info_text: - info_label = make_label(info_text, 'grid-info-label') + info_label = make_label(info_text, "grid-info-label") widget_box.pack_start(info_label, False, False, 0) # Download the cover art. @@ -553,26 +524,24 @@ class AlbumsGrid(Gtk.Overlay): artwork.set_loading(True) cover_art_filename_future = CacheManager.get_cover_art_filename( - item.album.coverArt, - before_download=lambda: GLib.idle_add(start_loading), + item.album.coverArt, before_download=lambda: GLib.idle_add(start_loading), ) cover_art_filename_future.add_done_callback( - lambda f: GLib.idle_add(on_artwork_downloaded, f)) + lambda f: GLib.idle_add(on_artwork_downloaded, f) + ) widget_box.show_all() return widget_box def reflow_grids( - self, - force_reload_from_master: bool = False, - selection_changed: bool = False, + self, force_reload_from_master: bool = False, selection_changed: bool = False, ): # Determine where the cuttoff is between the top and bottom grids. entries_before_fold = len(self.list_store) if self.current_selection is not None and self.items_per_row: entries_before_fold = ( - ((self.current_selection // self.items_per_row) + 1) - * self.items_per_row) + (self.current_selection // self.items_per_row) + 1 + ) * self.items_per_row if force_reload_from_master: # Just remove everything and re-add all of the items. @@ -606,8 +575,7 @@ class AlbumsGrid(Gtk.Overlay): del self.list_store_top[-1] if self.current_selection is not None: - to_select = self.grid_top.get_child_at_index( - self.current_selection) + to_select = self.grid_top.get_child_at_index(self.current_selection) if not to_select: return self.grid_top.select_child(to_select) @@ -621,10 +589,9 @@ class AlbumsGrid(Gtk.Overlay): model = self.list_store[self.current_selection] detail_element = AlbumWithSongs(model.album, cover_art_size=300) detail_element.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) - detail_element.connect('song-selected', lambda *a: None) + detail_element.connect("song-selected", lambda *a: None) self.detail_box_inner.pack_start(detail_element, True, True, 0) self.detail_box.show_all() diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index fbf5b0e..f952364 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -2,7 +2,8 @@ from random import randint from typing import Any, cast, List, Union import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gio, GLib, GObject, Gtk, Pango from sublime.cache_manager import CacheManager @@ -22,12 +23,12 @@ class ArtistsPanel(Gtk.Paned): """Defines the arist panel.""" __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -42,8 +43,7 @@ class ArtistsPanel(Gtk.Paned): self.artist_detail_panel = ArtistDetailPanel() self.artist_detail_panel.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) self.pack2(self.artist_detail_panel, True, False) @@ -70,16 +70,15 @@ class ArtistList(Gtk.Box): list_actions = Gtk.ActionBar() - refresh = IconButton( - 'view-refresh-symbolic', 'Refresh list of artists') - refresh.connect('clicked', lambda *a: self.update(force=True)) + refresh = IconButton("view-refresh-symbolic", "Refresh list of artists") + refresh.connect("clicked", lambda *a: self.update(force=True)) list_actions.pack_end(refresh) self.add(list_actions) self.loading_indicator = Gtk.ListBox() spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) - spinner = Gtk.Spinner(name='artist-list-spinner', active=True) + spinner = Gtk.Spinner(name="artist-list-spinner", active=True) spinner_row.add(spinner) self.loading_indicator.add(spinner_row) self.pack_start(self.loading_indicator, False, False, 0) @@ -87,32 +86,33 @@ class ArtistList(Gtk.Box): list_scroll_window = Gtk.ScrolledWindow(min_content_width=250) def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow: - label_text = [f'{util.esc(model.name)}'] + label_text = [f"{util.esc(model.name)}"] album_count = model.album_count if album_count: label_text.append( - '{} {}'.format( - album_count, util.pluralize('album', album_count))) + "{} {}".format(album_count, util.pluralize("album", album_count)) + ) row = Gtk.ListBoxRow( - action_name='app.go-to-artist', - action_target=GLib.Variant('s', model.artist_id), + action_name="app.go-to-artist", + action_target=GLib.Variant("s", model.artist_id), ) row.add( Gtk.Label( - label='\n'.join(label_text), + label="\n".join(label_text), use_markup=True, margin=12, halign=Gtk.Align.START, ellipsize=Pango.EllipsizeMode.END, max_width_chars=30, - )) + ) + ) row.show_all() return row self.artists_store = Gio.ListStore() - self.list = Gtk.ListBox(name='artist-list') + self.list = Gtk.ListBox(name="artist-list") self.list.bind_model(self.artists_store, create_artist_row) list_scroll_window.add(self.list) @@ -124,24 +124,17 @@ class ArtistList(Gtk.Box): on_failure=lambda self, e: self.loading_indicator.hide(), ) def update( - self, - artists: List[ArtistID3], - app_config: AppConfiguration, - **kwargs, + self, artists: List[ArtistID3], app_config: AppConfiguration, **kwargs, ): new_store = [] selected_idx = None for i, artist in enumerate(artists): - if (app_config.state - and app_config.state.selected_artist_id == artist.id): + if app_config.state and app_config.state.selected_artist_id == artist.id: selected_idx = i new_store.append( - _ArtistModel( - artist.id, - artist.name, - artist.get('albumCount', ''), - )) + _ArtistModel(artist.id, artist.name, artist.get("albumCount", ""),) + ) util.diff_model_store(self.artists_store, new_store) @@ -157,7 +150,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): """Defines the artists list.""" __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), @@ -167,7 +160,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): update_order_token = 0 def __init__(self, *args, **kwargs): - super().__init__(*args, name='artist-detail-panel', **kwargs) + super().__init__(*args, name="artist-detail-panel", **kwargs) self.albums: Union[List[AlbumID3], List[Child]] = [] self.artist_id = None @@ -178,8 +171,8 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): self.artist_artwork = SpinnerImage( loading=False, - image_name='artist-album-artwork', - spinner_name='artist-artwork-spinner', + image_name="artist-album-artwork", + spinner_name="artist-artwork-spinner", image_size=300, ) self.big_info_panel.pack_start(self.artist_artwork, False, False, 0) @@ -189,71 +182,66 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): # Action buttons (note we are packing end here, so we have to put them # in right-to-left). - self.artist_action_buttons = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL) + self.artist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - view_refresh_button = IconButton( - 'view-refresh-symbolic', 'Refresh artist info') - view_refresh_button.connect('clicked', self.on_view_refresh_click) - self.artist_action_buttons.pack_end( - view_refresh_button, False, False, 5) + view_refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info") + view_refresh_button.connect("clicked", self.on_view_refresh_click) + self.artist_action_buttons.pack_end(view_refresh_button, False, False, 5) download_all_btn = IconButton( - 'folder-download-symbolic', 'Download all songs by this artist') - download_all_btn.connect('clicked', self.on_download_all_click) + "folder-download-symbolic", "Download all songs by this artist" + ) + download_all_btn.connect("clicked", self.on_download_all_click) self.artist_action_buttons.pack_end(download_all_btn, False, False, 5) - artist_details_box.pack_start( - self.artist_action_buttons, False, False, 5) + artist_details_box.pack_start(self.artist_action_buttons, False, False, 5) artist_details_box.pack_start(Gtk.Box(), True, False, 0) - self.artist_indicator = self.make_label(name='artist-indicator') + self.artist_indicator = self.make_label(name="artist-indicator") artist_details_box.add(self.artist_indicator) - self.artist_name = self.make_label(name='artist-name') + self.artist_name = self.make_label(name="artist-name") artist_details_box.add(self.artist_name) self.artist_bio = self.make_label( - name='artist-bio', justify=Gtk.Justification.LEFT) + name="artist-bio", justify=Gtk.Justification.LEFT + ) self.artist_bio.set_line_wrap(True) artist_details_box.add(self.artist_bio) self.similar_artists_scrolledwindow = Gtk.ScrolledWindow() similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - self.similar_artists_label = self.make_label(name='similar-artists') + self.similar_artists_label = self.make_label(name="similar-artists") similar_artists_box.add(self.similar_artists_label) self.similar_artists_button_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL) + orientation=Gtk.Orientation.HORIZONTAL + ) similar_artists_box.add(self.similar_artists_button_box) self.similar_artists_scrolledwindow.add(similar_artists_box) artist_details_box.add(self.similar_artists_scrolledwindow) - self.artist_stats = self.make_label(name='artist-stats') + self.artist_stats = self.make_label(name="artist-stats") artist_details_box.add(self.artist_stats) self.play_shuffle_buttons = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - name='playlist-play-shuffle-buttons', + name="playlist-play-shuffle-buttons", ) play_button = IconButton( - 'media-playback-start-symbolic', - label='Play All', - relief=True, + "media-playback-start-symbolic", label="Play All", relief=True, ) - play_button.connect('clicked', self.on_play_all_clicked) + play_button.connect("clicked", self.on_play_all_clicked) self.play_shuffle_buttons.pack_start(play_button, False, False, 0) shuffle_button = IconButton( - 'media-playlist-shuffle-symbolic', - label='Shuffle All', - relief=True, + "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True, ) - shuffle_button.connect('clicked', self.on_shuffle_all_button) + shuffle_button.connect("clicked", self.on_shuffle_all_button) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) artist_details_box.add(self.play_shuffle_buttons) @@ -263,8 +251,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): self.albums_list = AlbumsListWithSongs() self.albums_list.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) artist_info_box.pack_start(self.albums_list, True, True, 0) @@ -274,11 +261,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): self.artist_id = app_config.state.selected_artist_id if app_config.state.selected_artist_id is None: self.artist_action_buttons.hide() - self.artist_indicator.set_text('') - self.artist_name.set_markup('') - self.artist_stats.set_markup('') + self.artist_indicator.set_text("") + self.artist_name.set_markup("") + self.artist_stats.set_markup("") - self.artist_bio.set_markup('') + self.artist_bio.set_markup("") self.similar_artists_scrolledwindow.hide() self.play_shuffle_buttons.hide() @@ -310,27 +297,21 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): if order_token != self.update_order_token: return - self.artist_indicator.set_text('ARTIST') - self.artist_name.set_markup(util.esc(f'{artist.name}')) + self.artist_indicator.set_text("ARTIST") + self.artist_name.set_markup(util.esc(f"{artist.name}")) self.artist_stats.set_markup(self.format_stats(artist)) self.update_artist_info( - artist.id, - force=force, - order_token=order_token, + artist.id, force=force, order_token=order_token, ) self.update_artist_artwork( - artist, - force=force, - order_token=order_token, + artist, force=force, order_token=order_token, ) - self.albums = artist.get('album', artist.get('child', [])) + self.albums = artist.get("album", artist.get("child", [])) self.albums_list.update(artist) - @util.async_callback( - lambda *a, **k: CacheManager.get_artist_info(*a, **k), - ) + @util.async_callback(lambda *a, **k: CacheManager.get_artist_info(*a, **k),) def update_artist_info( self, artist_info: ArtistInfo2, @@ -341,11 +322,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): if order_token != self.update_order_token: return - self.artist_bio.set_markup(util.esc(''.join(artist_info.biography))) + self.artist_bio.set_markup(util.esc("".join(artist_info.biography))) self.play_shuffle_buttons.show_all() if len(artist_info.similarArtist or []) > 0: - self.similar_artists_label.set_markup('Similar Artists: ') + self.similar_artists_label.set_markup("Similar Artists: ") for c in self.similar_artists_button_box.get_children(): self.similar_artists_button_box.remove(c) @@ -353,10 +334,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): self.similar_artists_button_box.add( Gtk.LinkButton( label=artist.name, - name='similar-artist-button', - action_name='app.go-to-artist', - action_target=GLib.Variant('s', artist.id), - )) + name="similar-artist-button", + action_name="app.go-to-artist", + action_target=GLib.Variant("s", artist.id), + ) + ) self.similar_artists_scrolledwindow.show_all() else: self.similar_artists_scrolledwindow.hide() @@ -383,41 +365,33 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): # ========================================================================= def on_view_refresh_click(self, *args): self.update_artist_view( - self.artist_id, - force=True, - order_token=self.update_order_token, + self.artist_id, force=True, order_token=self.update_order_token, ) def on_download_all_click(self, btn: Any): CacheManager.batch_download_songs( self.get_artist_song_ids(), before_download=lambda: self.update_artist_view( - self.artist_id, - order_token=self.update_order_token, + self.artist_id, order_token=self.update_order_token, ), on_song_download_complete=lambda i: self.update_artist_view( - self.artist_id, - order_token=self.update_order_token, + self.artist_id, order_token=self.update_order_token, ), ) def on_play_all_clicked(self, btn: Any): songs = self.get_artist_song_ids() self.emit( - 'song-clicked', - 0, - songs, - {'force_shuffle_state': False}, + "song-clicked", 0, songs, {"force_shuffle_state": False}, ) def on_shuffle_all_button(self, btn: Any): songs = self.get_artist_song_ids() self.emit( - 'song-clicked', - randint(0, - len(songs) - 1), + "song-clicked", + randint(0, len(songs) - 1), songs, - {'force_shuffle_state': True}, + {"force_shuffle_state": True}, ) # Helper Methods @@ -431,27 +405,18 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): self.albums_list.spinner.hide() self.artist_artwork.set_loading(False) - def make_label( - self, - text: str = None, - name: str = None, - **params, - ) -> Gtk.Label: + def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label: return Gtk.Label( - label=text, - name=name, - halign=Gtk.Align.START, - xalign=0, - **params, + label=text, name=name, halign=Gtk.Align.START, xalign=0, **params, ) def format_stats(self, artist: ArtistWithAlbumsID3) -> str: - album_count = artist.get('albumCount', 0) + album_count = artist.get("albumCount", 0) song_count = sum(a.songCount for a in artist.album) duration = sum(a.duration for a in artist.album) return util.dot_join( - '{} {}'.format(album_count, util.pluralize('album', album_count)), - '{} {}'.format(song_count, util.pluralize('song', song_count)), + "{} {}".format(album_count, util.pluralize("album", album_count)), + "{} {}".format(song_count, util.pluralize("song", song_count)), util.format_sequence_duration(duration), ) @@ -459,7 +424,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): songs = [] for album in CacheManager.get_artist(self.artist_id).result().album: album_songs = CacheManager.get_album(album.id).result() - for song in album_songs.get('song', []): + for song in album_songs.get("song", []): songs.append(song.id) return songs @@ -467,7 +432,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow): class AlbumsListWithSongs(Gtk.Overlay): __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), @@ -480,7 +445,7 @@ class AlbumsListWithSongs(Gtk.Overlay): self.add(self.box) self.spinner = Gtk.Spinner( - name='albumslist-with-songs-spinner', + name="albumslist-with-songs-spinner", active=False, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, @@ -499,7 +464,7 @@ class AlbumsListWithSongs(Gtk.Overlay): self.spinner.hide() return - new_albums = artist.get('album', artist.get('child', [])) + new_albums = artist.get("album", artist.get("child", [])) if self.albums == new_albums: # No need to do anything. @@ -513,10 +478,9 @@ class AlbumsListWithSongs(Gtk.Overlay): for album in self.albums: album_with_songs = AlbumWithSongs(album, show_artist_name=False) album_with_songs.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) - album_with_songs.connect('song-selected', self.on_song_selected) + album_with_songs.connect("song-selected", self.on_song_selected) album_with_songs.show_all() self.box.add(album_with_songs) diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py index 19e05e5..046b74e 100644 --- a/sublime/ui/browse.py +++ b/sublime/ui/browse.py @@ -1,7 +1,8 @@ from typing import Any, List, Optional, Tuple, Type, Union import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from sublime.cache_manager import CacheManager @@ -15,12 +16,12 @@ class BrowsePanel(Gtk.Overlay): """Defines the arist panel.""" __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -36,19 +37,17 @@ class BrowsePanel(Gtk.Overlay): self.root_directory_listing = ListAndDrilldown(IndexList) self.root_directory_listing.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) self.root_directory_listing.connect( - 'refresh-window', - lambda _, *args: self.emit('refresh-window', *args), + "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) scrolled_window.add(self.root_directory_listing) self.add(scrolled_window) self.spinner = Gtk.Spinner( - name='browse-spinner', + name="browse-spinner", active=True, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, @@ -66,9 +65,7 @@ class BrowsePanel(Gtk.Overlay): return self.root_directory_listing.update( - id_stack, - app_config=app_config, - force=force, + id_stack, app_config=app_config, force=force, ) self.spinner.hide() @@ -81,30 +78,25 @@ class BrowsePanel(Gtk.Overlay): current_dir_id = app_config.state.selected_browse_element_id while directory is None or directory.parent is not None: directory = CacheManager.get_music_directory( - current_dir_id, - before_download=self.spinner.show, + current_dir_id, before_download=self.spinner.show, ).result() id_stack.append(directory.id) current_dir_id = directory.parent return id_stack, update_order_token - path_fut = CacheManager.create_future( - calculate_path, - self.update_order_token, - ) - path_fut.add_done_callback( - lambda f: GLib.idle_add(do_update, *f.result())) + path_fut = CacheManager.create_future(calculate_path, self.update_order_token,) + path_fut.add_done_callback(lambda f: GLib.idle_add(do_update, *f.result())) class ListAndDrilldown(Gtk.Paned): __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -118,12 +110,10 @@ class ListAndDrilldown(Gtk.Paned): self.list = list_type() self.list.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) self.list.connect( - 'refresh-window', - lambda _, *args: self.emit('refresh-window', *args), + "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) self.pack1(self.list, False, False) @@ -149,10 +139,7 @@ class ListAndDrilldown(Gtk.Paned): # away the drilldown. if isinstance(self.drilldown, ListAndDrilldown): self.drilldown.update( - id_stack[:-1], - app_config, - force=force, - directory_id=id_stack[-1], + id_stack[:-1], app_config, force=force, directory_id=id_stack[-1], ) return self.id_stack = id_stack @@ -161,18 +148,13 @@ class ListAndDrilldown(Gtk.Paned): self.remove(self.drilldown) self.drilldown = ListAndDrilldown(MusicDirectoryList) self.drilldown.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) self.drilldown.connect( - 'refresh-window', - lambda _, *args: self.emit('refresh-window', *args), + "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) self.drilldown.update( - id_stack[:-1], - app_config, - force=force, - directory_id=id_stack[-1], + id_stack[:-1], app_config, force=force, directory_id=id_stack[-1], ) self.drilldown.show_all() self.pack2(self.drilldown, True, False) @@ -180,12 +162,12 @@ class ListAndDrilldown(Gtk.Paned): class DrilldownList(Gtk.Box): __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -200,24 +182,23 @@ class DrilldownList(Gtk.Box): def __init__(self, element: Union[Child, Artist]): GObject.GObject.__init__(self) self.id = element.id - self.name = ( - element.name if isinstance(element, Artist) else element.title) - self.is_dir = element.get('isDir', True) + self.name = element.name if isinstance(element, Artist) else element.title + self.is_dir = element.get("isDir", True) def __init__(self): Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) list_actions = Gtk.ActionBar() - refresh = IconButton('view-refresh-symbolic', 'Refresh folder') - refresh.connect('clicked', self.on_refresh_clicked) + refresh = IconButton("view-refresh-symbolic", "Refresh folder") + refresh.connect("clicked", self.on_refresh_clicked) list_actions.pack_end(refresh) self.add(list_actions) self.loading_indicator = Gtk.ListBox() spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) - spinner = Gtk.Spinner(name='drilldown-list-spinner', active=True) + spinner = Gtk.Spinner(name="drilldown-list-spinner", active=True) spinner_row.add(spinner) self.loading_indicator.add(spinner_row) self.pack_start(self.loading_indicator, False, False, 0) @@ -231,36 +212,32 @@ class DrilldownList(Gtk.Box): scrollbox.add(self.list) self.directory_song_store = Gtk.ListStore( - str, # cache status - str, # title - str, # duration - str, # song ID + str, str, str, str, # cache status # title # duration # song ID ) self.directory_song_list = Gtk.TreeView( model=self.directory_song_store, - name='album-songs-list', + name="album-songs-list", headers_visible=False, ) - self.directory_song_list.get_selection().set_mode( - Gtk.SelectionMode.MULTIPLE) + self.directory_song_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) # Song status column. renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(30, 35) - column = Gtk.TreeViewColumn('', renderer, icon_name=0) + column = Gtk.TreeViewColumn("", renderer, icon_name=0) column.set_resizable(True) self.directory_song_list.append_column(column) + self.directory_song_list.append_column(SongListColumn("TITLE", 1, bold=True)) self.directory_song_list.append_column( - SongListColumn('TITLE', 1, bold=True)) - self.directory_song_list.append_column( - SongListColumn('DURATION', 2, align=1, width=40)) + SongListColumn("DURATION", 2, align=1, width=40) + ) + self.directory_song_list.connect("row-activated", self.on_song_activated) self.directory_song_list.connect( - 'row-activated', self.on_song_activated) - self.directory_song_list.connect( - 'button-press-event', self.on_song_button_press) + "button-press-event", self.on_song_button_press + ) scrollbox.add(self.directory_song_list) self.scroll_window.add(scrollbox) @@ -269,17 +246,13 @@ class DrilldownList(Gtk.Box): def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): # The song ID is in the last column of the model. self.emit( - 'song-clicked', + "song-clicked", idx.get_indices()[0], [m[-1] for m in self.directory_song_store], {}, ) - def on_song_button_press( - self, - tree: Gtk.TreeView, - event: Gdk.EventButton, - ) -> bool: + def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton,) -> bool: if event.button == 3: # Right click clicked_path = tree.get_path_at_pos(event.x, event.y) if not clicked_path: @@ -297,10 +270,8 @@ class DrilldownList(Gtk.Box): song_ids = [self.directory_song_store[p][-1] for p in paths] # Used to adjust for the header row. - bin_coords = tree.convert_tree_to_bin_window_coords( - event.x, event.y) - widget_coords = tree.convert_tree_to_widget_coords( - event.x, event.y) + bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y) + widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y) util.show_song_popover( song_ids, @@ -322,23 +293,21 @@ class DrilldownList(Gtk.Box): selected_dir_idx = None for idx, el in enumerate(elements or []): - if el.get('isDir', True): - new_directories_store.append( - DrilldownList.DrilldownElement(el)) + if el.get("isDir", True): + new_directories_store.append(DrilldownList.DrilldownElement(el)) if el.id == self.selected_id: selected_dir_idx = idx else: new_songs_store.append( [ - util.get_cached_status_icon( - CacheManager.get_cached_status(el)), + util.get_cached_status_icon(CacheManager.get_cached_status(el)), util.esc(el.title), util.format_song_duration(el.duration), el.id, - ]) + ] + ) - util.diff_model_store( - self.drilldown_directories_store, new_directories_store) + util.diff_model_store(self.drilldown_directories_store, new_directories_store) util.diff_song_store(self.directory_song_store, new_songs_store) @@ -361,23 +330,22 @@ class DrilldownList(Gtk.Box): self.loading_indicator.hide() - def create_row( - self, model: 'DrilldownList.DrilldownElement') -> Gtk.ListBoxRow: + def create_row(self, model: "DrilldownList.DrilldownElement") -> Gtk.ListBoxRow: row = Gtk.ListBoxRow( - action_name='app.browse-to', - action_target=GLib.Variant('s', model.id), + action_name="app.browse-to", action_target=GLib.Variant("s", model.id), ) rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) rowbox.add( Gtk.Label( - label=f'{util.esc(model.name)}', + label=f"{util.esc(model.name)}", use_markup=True, margin=8, halign=Gtk.Align.START, ellipsize=Pango.EllipsizeMode.END, - )) + ) + ) - icon = Gio.ThemedIcon(name='go-next-symbolic') + icon = Gio.ThemedIcon(name="go-next-symbolic") image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON) rowbox.pack_end(image, False, False, 5) row.add(rowbox) @@ -398,9 +366,7 @@ class IndexList(DrilldownList): self.update_order_token += 1 self.selected_id = selected_id self.update_store( - force=force, - app_config=app_config, - order_token=self.update_order_token, + force=force, app_config=app_config, order_token=self.update_order_token, ) def on_refresh_clicked(self, _: Any): @@ -447,8 +413,7 @@ class MusicDirectoryList(DrilldownList): ) def on_refresh_clicked(self, _: Any): - self.update( - self.selected_id, force=True, directory_id=self.directory_id) + self.update(self.selected_id, force=True, directory_id=self.directory_id) @util.async_callback( lambda *a, **k: CacheManager.get_music_directory(*a, **k), diff --git a/sublime/ui/common/__init__.py b/sublime/ui/common/__init__.py index bc93b24..dc2f0d8 100644 --- a/sublime/ui/common/__init__.py +++ b/sublime/ui/common/__init__.py @@ -5,10 +5,10 @@ from .song_list_column import SongListColumn from .spinner_image import SpinnerImage __all__ = ( - 'AlbumWithSongs', - 'EditFormDialog', - 'IconButton', - 'IconToggleButton', - 'SongListColumn', - 'SpinnerImage', + "AlbumWithSongs", + "EditFormDialog", + "IconButton", + "IconToggleButton", + "SongListColumn", + "SpinnerImage", ) diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py index f1859ed..a6e74ad 100644 --- a/sublime/ui/common/album_with_songs.py +++ b/sublime/ui/common/album_with_songs.py @@ -2,7 +2,8 @@ from random import randint from typing import Any, Union import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GLib, GObject, Gtk, Pango from sublime.cache_manager import CacheManager @@ -16,12 +17,8 @@ from sublime.ui.common.spinner_image import SpinnerImage class AlbumWithSongs(Gtk.Box): __gsignals__ = { - 'song-selected': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (), - ), - 'song-clicked': ( + "song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (),), + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), @@ -40,13 +37,12 @@ class AlbumWithSongs(Gtk.Box): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) artist_artwork = SpinnerImage( loading=False, - image_name='artist-album-list-artwork', - spinner_name='artist-artwork-spinner', + image_name="artist-album-list-artwork", + spinner_name="artist-artwork-spinner", image_size=cover_art_size, ) # Account for 10px margin on all sides with "+ 20". - artist_artwork.set_size_request( - cover_art_size + 20, cover_art_size + 20) + artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20) box.pack_start(artist_artwork, False, False, 0) box.pack_start(Gtk.Box(), True, True, 0) self.pack_start(box, False, False, 0) @@ -56,64 +52,62 @@ class AlbumWithSongs(Gtk.Box): artist_artwork.set_loading(False) cover_art_filename_future = CacheManager.get_cover_art_filename( - album.coverArt, - before_download=lambda: artist_artwork.set_loading(True), + album.coverArt, before_download=lambda: artist_artwork.set_loading(True), ) cover_art_filename_future.add_done_callback( - lambda f: GLib.idle_add(cover_art_future_done, f)) + lambda f: GLib.idle_add(cover_art_future_done, f) + ) album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - album_title_and_buttons = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL) + album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) # TODO (#43): deal with super long-ass titles album_title_and_buttons.add( Gtk.Label( - label=album.get('name', album.get('title')), - name='artist-album-list-album-name', + label=album.get("name", album.get("title")), + name="artist-album-list-album-name", halign=Gtk.Align.START, ellipsize=Pango.EllipsizeMode.END, - )) + ) + ) self.play_btn = IconButton( - 'media-playback-start-symbolic', - 'Play all songs in this album', + "media-playback-start-symbolic", + "Play all songs in this album", sensitive=False, ) - self.play_btn.connect('clicked', self.play_btn_clicked) + self.play_btn.connect("clicked", self.play_btn_clicked) album_title_and_buttons.pack_start(self.play_btn, False, False, 5) self.shuffle_btn = IconButton( - 'media-playlist-shuffle-symbolic', - 'Shuffle all songs in this album', + "media-playlist-shuffle-symbolic", + "Shuffle all songs in this album", sensitive=False, ) - self.shuffle_btn.connect('clicked', self.shuffle_btn_clicked) + self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked) album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5) self.play_next_btn = IconButton( - 'go-top-symbolic', - 'Play all of the songs in this album next', - action_name='app.play-next', + "go-top-symbolic", + "Play all of the songs in this album next", + action_name="app.play-next", ) album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5) self.add_to_queue_btn = IconButton( - 'go-jump-symbolic', - 'Add all the songs in this album to the end of the play queue', - action_name='app.add-to-queue', + "go-jump-symbolic", + "Add all the songs in this album to the end of the play queue", + action_name="app.add-to-queue", ) - album_title_and_buttons.pack_start( - self.add_to_queue_btn, False, False, 5) + album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False, 5) self.download_all_btn = IconButton( - 'folder-download-symbolic', - 'Download all songs in this album', + "folder-download-symbolic", + "Download all songs in this album", sensitive=False, ) - self.download_all_btn.connect('clicked', self.on_download_all_click) - album_title_and_buttons.pack_end( - self.download_all_btn, False, False, 5) + self.download_all_btn.connect("clicked", self.on_download_all_click) + album_title_and_buttons.pack_end(self.download_all_btn, False, False, 5) album_details.add(album_title_and_buttons) @@ -122,30 +116,26 @@ class AlbumWithSongs(Gtk.Box): album.year, album.genre, util.format_sequence_duration(album.duration) - if album.get('duration') else None, + if album.get("duration") + else None, ] album_details.add( Gtk.Label( - label=util.dot_join(*stats), - halign=Gtk.Align.START, - margin_left=10, - )) - - self.album_song_store = Gtk.ListStore( - str, # cache status - str, # title - str, # duration - str, # song ID + label=util.dot_join(*stats), halign=Gtk.Align.START, margin_left=10, + ) ) - self.loading_indicator = Gtk.Spinner( - name='album-list-song-list-spinner') + self.album_song_store = Gtk.ListStore( + str, str, str, str, # cache status # title # duration # song ID + ) + + self.loading_indicator = Gtk.Spinner(name="album-list-song-list-spinner") album_details.add(self.loading_indicator) self.album_songs = Gtk.TreeView( model=self.album_song_store, - name='album-songs-list', + name="album-songs-list", headers_visible=False, margin_top=15, margin_left=10, @@ -157,19 +147,18 @@ class AlbumWithSongs(Gtk.Box): # Song status column. renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(30, 35) - column = Gtk.TreeViewColumn('', renderer, icon_name=0) + column = Gtk.TreeViewColumn("", renderer, icon_name=0) column.set_resizable(True) self.album_songs.append_column(column) - self.album_songs.append_column(SongListColumn('TITLE', 1, bold=True)) - self.album_songs.append_column( - SongListColumn('DURATION', 2, align=1, width=40)) + self.album_songs.append_column(SongListColumn("TITLE", 1, bold=True)) + self.album_songs.append_column(SongListColumn("DURATION", 2, align=1, width=40)) - self.album_songs.connect('row-activated', self.on_song_activated) - self.album_songs.connect( - 'button-press-event', self.on_song_button_press) + self.album_songs.connect("row-activated", self.on_song_activated) + self.album_songs.connect("button-press-event", self.on_song_button_press) self.album_songs.get_selection().connect( - 'changed', self.on_song_selection_change) + "changed", self.on_song_selection_change + ) album_details.add(self.album_songs) self.pack_end(album_details, True, True, 0) @@ -180,12 +169,12 @@ class AlbumWithSongs(Gtk.Box): # ========================================================================= def on_song_selection_change(self, event: Any): if not self.album_songs.has_focus(): - self.emit('song-selected') + self.emit("song-selected") def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): # The song ID is in the last column of the model. self.emit( - 'song-clicked', + "song-clicked", idx.get_indices()[0], [m[-1] for m in self.album_song_store], {}, @@ -212,10 +201,8 @@ class AlbumWithSongs(Gtk.Box): song_ids = [self.album_song_store[p][-1] for p in paths] # Used to adjust for the header row. - bin_coords = tree.convert_tree_to_bin_window_coords( - event.x, event.y) - widget_coords = tree.convert_tree_to_widget_coords( - event.x, event.y) + bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y) + widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y) util.show_song_popover( song_ids, @@ -241,20 +228,16 @@ class AlbumWithSongs(Gtk.Box): def play_btn_clicked(self, btn: Any): song_ids = [x[-1] for x in self.album_song_store] self.emit( - 'song-clicked', - 0, - song_ids, - {'force_shuffle_state': False}, + "song-clicked", 0, song_ids, {"force_shuffle_state": False}, ) def shuffle_btn_clicked(self, btn: Any): song_ids = [x[-1] for x in self.album_song_store] self.emit( - 'song-clicked', - randint(0, - len(self.album_song_store) - 1), + "song-clicked", + randint(0, len(self.album_song_store) - 1), song_ids, - {'force_shuffle_state': True}, + {"force_shuffle_state": True}, ) # Helper Methods @@ -287,22 +270,20 @@ class AlbumWithSongs(Gtk.Box): ): new_store = [ [ - util.get_cached_status_icon( - CacheManager.get_cached_status(song)), + util.get_cached_status_icon(CacheManager.get_cached_status(song)), util.esc(song.title), util.format_song_duration(song.duration), song.id, - ] for song in (album.get('child') or album.get('song') or []) + ] + for song in (album.get("child") or album.get("song") or []) ] song_ids = [song[-1] for song in new_store] self.play_btn.set_sensitive(True) self.shuffle_btn.set_sensitive(True) - self.play_next_btn.set_action_target_value( - GLib.Variant('as', song_ids)) - self.add_to_queue_btn.set_action_target_value( - GLib.Variant('as', song_ids)) + self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids)) + self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids)) self.download_all_btn.set_sensitive(True) util.diff_song_store(self.album_song_store, new_store) diff --git a/sublime/ui/common/edit_form_dialog.py b/sublime/ui/common/edit_form_dialog.py index 9ee5ddc..8017080 100644 --- a/sublime/ui/common/edit_form_dialog.py +++ b/sublime/ui/common/edit_form_dialog.py @@ -1,7 +1,8 @@ from typing import Any, List, Optional, Tuple import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk TextFieldDescription = Tuple[str, str, bool] @@ -25,25 +26,22 @@ class EditFormDialog(Gtk.Dialog): """ Gets the friendly object name. Can be overridden. """ - return obj.name if obj else '' + return obj.name if obj else "" def get_default_object(self): return None def __init__(self, parent: Any, existing_object: Any = None): editing = existing_object is not None - title = getattr(self, 'title', None) + title = getattr(self, "title", None) if not title: if editing: - title = f'Edit {self.get_object_name(existing_object)}' + title = f"Edit {self.get_object_name(existing_object)}" else: - title = f'Create New {self.entity_name}' + title = f"Create New {self.entity_name}" Gtk.Dialog.__init__( - self, - title=title, - transient_for=parent, - flags=0, + self, title=title, transient_for=parent, flags=0, ) if not existing_object: existing_object = self.get_default_object() @@ -55,22 +53,18 @@ class EditFormDialog(Gtk.Dialog): content_area = self.get_content_area() content_grid = Gtk.Grid( - column_spacing=10, - row_spacing=5, - margin_left=10, - margin_right=10, + column_spacing=10, row_spacing=5, margin_left=10, margin_right=10, ) # Add the text entries to the content area. i = 0 for label, value_field_name, is_password in self.text_fields: - entry_label = Gtk.Label(label=label + ':') + entry_label = Gtk.Label(label=label + ":") entry_label.set_halign(Gtk.Align.START) content_grid.attach(entry_label, 0, i, 1, 1) entry = Gtk.Entry( - text=getattr(existing_object, value_field_name, ''), - hexpand=True, + text=getattr(existing_object, value_field_name, ""), hexpand=True, ) if is_password: entry.set_visibility(False) @@ -80,7 +74,7 @@ class EditFormDialog(Gtk.Dialog): i += 1 for label, value_field_name, options in self.option_fields: - entry_label = Gtk.Label(label=label + ':') + entry_label = Gtk.Label(label=label + ":") entry_label.set_halign(Gtk.Align.START) content_grid.attach(entry_label, 0, i, 1, 1) @@ -105,22 +99,27 @@ class EditFormDialog(Gtk.Dialog): # Add the boolean entries to the content area. for label, value_field_name in self.boolean_fields: - entry_label = Gtk.Label(label=label + ':') + entry_label = Gtk.Label(label=label + ":") entry_label.set_halign(Gtk.Align.START) content_grid.attach(entry_label, 0, i, 1, 1) # Put the checkbox in the right box. Note we have to pad here # since the checkboxes are smaller than the text fields. checkbox = Gtk.CheckButton( - active=getattr(existing_object, value_field_name, False)) + active=getattr(existing_object, value_field_name, False) + ) self.data[value_field_name] = checkbox content_grid.attach(checkbox, 1, i, 1, 1) i += 1 # Add the spin button entries to the content area. - for (label, value_field_name, range_config, - default_value) in self.numeric_fields: - entry_label = Gtk.Label(label=label + ':') + for ( + label, + value_field_name, + range_config, + default_value, + ) in self.numeric_fields: + entry_label = Gtk.Label(label=label + ":") entry_label.set_halign(Gtk.Align.START) content_grid.attach(entry_label, 0, i, 1, 1) @@ -128,7 +127,8 @@ class EditFormDialog(Gtk.Dialog): # since the checkboxes are smaller than the text fields. spin_button = Gtk.SpinButton.new_with_range(*range_config) spin_button.set_value( - getattr(existing_object, value_field_name, default_value)) + getattr(existing_object, value_field_name, default_value) + ) self.data[value_field_name] = spin_button content_grid.attach(spin_button, 1, i, 1, 1) i += 1 diff --git a/sublime/ui/common/icon_button.py b/sublime/ui/common/icon_button.py index f59e814..78f1134 100644 --- a/sublime/ui/common/icon_button.py +++ b/sublime/ui/common/icon_button.py @@ -1,7 +1,8 @@ from typing import Optional import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk @@ -9,7 +10,7 @@ class IconButton(Gtk.Button): def __init__( self, icon_name: Optional[str], - tooltip_text: str = '', + tooltip_text: str = "", relief: bool = False, icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, label: str = None, @@ -18,8 +19,7 @@ class IconButton(Gtk.Button): Gtk.Button.__init__(self, **kwargs) self.icon_size = icon_size - box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, name='icon-button-box') + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") self.image = Gtk.Image() self.image.set_from_icon_name(icon_name, self.icon_size) @@ -42,7 +42,7 @@ class IconToggleButton(Gtk.ToggleButton): def __init__( self, icon_name: Optional[str], - tooltip_text: str = '', + tooltip_text: str = "", relief: bool = False, icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, label: str = None, @@ -50,8 +50,7 @@ class IconToggleButton(Gtk.ToggleButton): ): Gtk.ToggleButton.__init__(self, **kwargs) self.icon_size = icon_size - box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, name='icon-button-box') + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") self.image = Gtk.Image() self.image.set_from_icon_name(icon_name, self.icon_size) diff --git a/sublime/ui/common/song_list_column.py b/sublime/ui/common/song_list_column.py index d569004..6b827f3 100644 --- a/sublime/ui/common/song_list_column.py +++ b/sublime/ui/common/song_list_column.py @@ -1,5 +1,6 @@ import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Pango diff --git a/sublime/ui/common/spinner_image.py b/sublime/ui/common/spinner_image.py index 36cd875..14ba6ba 100644 --- a/sublime/ui/common/spinner_image.py +++ b/sublime/ui/common/spinner_image.py @@ -1,7 +1,8 @@ from typing import Optional import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import GdkPixbuf, Gtk @@ -29,14 +30,11 @@ class SpinnerImage(Gtk.Overlay): self.add_overlay(self.spinner) def set_from_file(self, filename: Optional[str]): - if filename == '': + if filename == "": filename = None if self.image_size is not None and filename: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - filename, - self.image_size, - self.image_size, - True, + filename, self.image_size, self.image_size, True, ) self.image.set_from_pixbuf(pixbuf) else: diff --git a/sublime/ui/configure_servers.py b/sublime/ui/configure_servers.py index f6b47da..6229409 100644 --- a/sublime/ui/configure_servers.py +++ b/sublime/ui/configure_servers.py @@ -2,7 +2,8 @@ import subprocess from typing import Any import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import GObject, Gtk from sublime.config import AppConfiguration, ServerConfiguration @@ -11,27 +12,27 @@ from sublime.ui.common import EditFormDialog, IconButton class EditServerDialog(EditFormDialog): - entity_name: str = 'Server' + entity_name: str = "Server" initial_size = (450, 250) text_fields = [ - ('Name', 'name', False), - ('Server address', 'server_address', False), - ('Local network address', 'local_network_address', False), - ('Local network SSID', 'local_network_ssid', False), - ('Username', 'username', False), - ('Password', 'password', True), + ("Name", "name", False), + ("Server address", "server_address", False), + ("Local network address", "local_network_address", False), + ("Local network SSID", "local_network_ssid", False), + ("Username", "username", False), + ("Password", "password", True), ] boolean_fields = [ - ('Play queue sync enabled', 'sync_enabled'), - ('Do not verify certificate', 'disable_cert_verify'), + ("Play queue sync enabled", "sync_enabled"), + ("Do not verify certificate", "disable_cert_verify"), ] def __init__(self, *args, **kwargs): - test_server = Gtk.Button(label='Test Connection to Server') - test_server.connect('clicked', self.on_test_server_clicked) + test_server = Gtk.Button(label="Test Connection to Server") + test_server.connect("clicked", self.on_test_server_clicked) - open_in_browser = Gtk.Button(label='Open in Browser') - open_in_browser.connect('clicked', self.on_open_in_browser_clicked) + open_in_browser = Gtk.Button(label="Open in Browser") + open_in_browser.connect("clicked", self.on_open_in_browser_clicked) self.extra_buttons = [(test_server, None), (open_in_browser, None)] @@ -39,13 +40,13 @@ class EditServerDialog(EditFormDialog): def on_test_server_clicked(self, event: Any): # Instantiate the server. - server_address = self.data['server_address'].get_text() + server_address = self.data["server_address"].get_text() server = Server( - name=self.data['name'].get_text(), + name=self.data["name"].get_text(), hostname=server_address, - username=self.data['username'].get_text(), - password=self.data['password'].get_text(), - disable_cert_verify=self.data['disable_cert_verify'].get_active(), + username=self.data["username"].get_text(), + password=self.data["password"].get_text(), + disable_cert_verify=self.data["disable_cert_verify"].get_active(), ) # Try to ping, and show a message box with whether or not it worked. @@ -55,40 +56,48 @@ class EditServerDialog(EditFormDialog): transient_for=self, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, - text='Connection to server successful.', + text="Connection to server successful.", ) dialog.format_secondary_markup( - f'Connection to {server_address} successful.') + f"Connection to {server_address} successful." + ) except Exception as err: dialog = Gtk.MessageDialog( transient_for=self, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, - text='Connection to server unsuccessful.', + text="Connection to server unsuccessful.", ) dialog.format_secondary_markup( - f'Connection to {server_address} resulted in the following ' - f'error:\n\n{err}') + f"Connection to {server_address} resulted in the following " + f"error:\n\n{err}" + ) dialog.run() dialog.destroy() def on_open_in_browser_clicked(self, event: Any): - subprocess.call(['xdg-open', self.data['server_address'].get_text()]) + subprocess.call(["xdg-open", self.data["server_address"].get_text()]) class ConfigureServersDialog(Gtk.Dialog): __gsignals__ = { - 'server-list-changed': - (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )), - 'connected-server-changed': - (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )), + "server-list-changed": ( + GObject.SignalFlags.RUN_FIRST, + GObject.TYPE_NONE, + (object,), + ), + "connected-server-changed": ( + GObject.SignalFlags.RUN_FIRST, + GObject.TYPE_NONE, + (object,), + ), } def __init__(self, parent: Any, config: AppConfiguration): Gtk.Dialog.__init__( self, - title='Configure Servers', + title="Configure Servers", transient_for=parent, flags=0, add_buttons=(), @@ -104,8 +113,9 @@ class ConfigureServersDialog(Gtk.Dialog): # Server List self.server_list = Gtk.ListBox(activate_on_single_click=False) self.server_list.connect( - 'selected-rows-changed', self.server_list_on_selected_rows_changed) - self.server_list.connect('row-activated', self.on_server_list_activate) + "selected-rows-changed", self.server_list_on_selected_rows_changed + ) + self.server_list.connect("row-activated", self.on_server_list_activate) flowbox.pack_start(self.server_list, True, True, 10) button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) @@ -113,45 +123,47 @@ class ConfigureServersDialog(Gtk.Dialog): # Add all of the buttons to the button box. self.buttons = [ ( - IconButton( - 'document-edit-symbolic', - label='Edit...', - relief=True, - ), lambda e: self.on_edit_clicked(False), 'start', True), + IconButton("document-edit-symbolic", label="Edit...", relief=True,), + lambda e: self.on_edit_clicked(False), + "start", + True, + ), + ( + IconButton("list-add-symbolic", label="Add...", relief=True,), + lambda e: self.on_edit_clicked(True), + "start", + False, + ), + ( + IconButton("list-remove-symbolic", label="Remove", relief=True,), + self.on_remove_clicked, + "start", + True, + ), + ( + IconButton("window-close-symbolic", label="Close", relief=True,), + lambda _: self.close(), + "end", + False, + ), ( IconButton( - 'list-add-symbolic', - label='Add...', - relief=True, - ), lambda e: self.on_edit_clicked(True), 'start', False), - ( - IconButton( - 'list-remove-symbolic', - label='Remove', - relief=True, - ), self.on_remove_clicked, 'start', True), - ( - IconButton( - 'window-close-symbolic', - label='Close', - relief=True, - ), lambda _: self.close(), 'end', False), - ( - IconButton( - 'network-transmit-receive-symbolic', - label='Connect', - relief=True, - ), self.on_connect_clicked, 'end', True), + "network-transmit-receive-symbolic", label="Connect", relief=True, + ), + self.on_connect_clicked, + "end", + True, + ), ] for button_cfg in self.buttons: btn, action, pack_end, requires_selection = button_cfg - if pack_end == 'end': + if pack_end == "end": button_box.pack_end(btn, False, True, 5) else: button_box.pack_start(btn, False, True, 5) - btn.connect('clicked', action) + btn.connect("clicked", action) flowbox.pack_end(button_box, False, False, 0) @@ -174,8 +186,7 @@ class ConfigureServersDialog(Gtk.Dialog): image = Gtk.Image(margin=5) if i == self.selected_server_index: image.set_from_icon_name( - 'network-transmit-receive-symbolic', - Gtk.IconSize.SMALL_TOOLBAR, + "network-transmit-receive-symbolic", Gtk.IconSize.SMALL_TOOLBAR, ) box.add(image) @@ -187,41 +198,37 @@ class ConfigureServersDialog(Gtk.Dialog): # Show them, and select the current server. self.show_all() - if (self.selected_server_index is not None - and self.selected_server_index >= 0): + if self.selected_server_index is not None and self.selected_server_index >= 0: self.server_list.select_row( - self.server_list.get_row_at_index(self.selected_server_index)) + self.server_list.get_row_at_index(self.selected_server_index) + ) def on_remove_clicked(self, event: Any): selected = self.server_list.get_selected_row() if selected: del self.server_configs[selected.get_index()] self.refresh_server_list() - self.emit('server-list-changed', self.server_configs) + self.emit("server-list-changed", self.server_configs) def on_edit_clicked(self, add: bool): if add: dialog = EditServerDialog(self) else: selected_index = self.server_list.get_selected_row().get_index() - dialog = EditServerDialog( - self, self.server_configs[selected_index]) + dialog = EditServerDialog(self, self.server_configs[selected_index]) result = dialog.run() if result == Gtk.ResponseType.OK: # Create a new server configuration to use. new_config = ServerConfiguration( - name=dialog.data['name'].get_text(), - server_address=dialog.data['server_address'].get_text(), - local_network_address=dialog.data['local_network_address'] - .get_text(), - local_network_ssid=dialog.data['local_network_ssid'].get_text( - ), - username=dialog.data['username'].get_text(), - password=dialog.data['password'].get_text(), - sync_enabled=dialog.data['sync_enabled'].get_active(), - disable_cert_verify=dialog.data['disable_cert_verify'] - .get_active(), + name=dialog.data["name"].get_text(), + server_address=dialog.data["server_address"].get_text(), + local_network_address=dialog.data["local_network_address"].get_text(), + local_network_ssid=dialog.data["local_network_ssid"].get_text(), + username=dialog.data["username"].get_text(), + password=dialog.data["password"].get_text(), + sync_enabled=dialog.data["sync_enabled"].get_active(), + disable_cert_verify=dialog.data["disable_cert_verify"].get_active(), ) if add: @@ -230,7 +237,7 @@ class ConfigureServersDialog(Gtk.Dialog): self.server_configs[selected_index] = new_config self.refresh_server_list() - self.emit('server-list-changed', self.server_configs) + self.emit("server-list-changed", self.server_configs) dialog.destroy() @@ -239,7 +246,7 @@ class ConfigureServersDialog(Gtk.Dialog): def on_connect_clicked(self, event: Any): selected_index = self.server_list.get_selected_row().get_index() - self.emit('connected-server-changed', selected_index) + self.emit("connected-server-changed", selected_index) self.close() def server_list_on_selected_rows_changed(self, event: Any): diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 6373768..3f95a39 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -2,39 +2,32 @@ from datetime import datetime from typing import Any, Callable, Set import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from sublime.cache_manager import CacheManager, SearchResult from sublime.config import AppConfiguration -from sublime.ui import ( - albums, artists, browse, player_controls, playlists, util) +from sublime.ui import albums, artists, browse, player_controls, playlists, util from sublime.ui.common import SpinnerImage class MainWindow(Gtk.ApplicationWindow): """Defines the main window for Sublime Music.""" + __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'songs-removed': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (object, ), - ), - 'refresh-window': ( + "songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),), + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), ), - 'go-to': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (str, str), - ), + "go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str),), } def __init__(self, *args, **kwargs): @@ -48,20 +41,20 @@ class MainWindow(Gtk.ApplicationWindow): Browse=browse.BrowsePanel(), Playlists=playlists.PlaylistsPanel(), ) - self.stack.set_transition_type( - Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) self.titlebar = self._create_headerbar(self.stack) self.set_titlebar(self.titlebar) self.player_controls = player_controls.PlayerControls() self.player_controls.connect( - 'song-clicked', lambda _, *a: self.emit('song-clicked', *a)) + "song-clicked", lambda _, *a: self.emit("song-clicked", *a) + ) self.player_controls.connect( - 'songs-removed', lambda _, *a: self.emit('songs-removed', *a)) + "songs-removed", lambda _, *a: self.emit("songs-removed", *a) + ) self.player_controls.connect( - 'refresh-window', - lambda _, *args: self.emit('refresh-window', *args), + "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -69,21 +62,23 @@ class MainWindow(Gtk.ApplicationWindow): flowbox.pack_start(self.player_controls, False, True, 0) self.add(flowbox) - self.connect('button-release-event', self._on_button_release) + self.connect("button-release-event", self._on_button_release) def update(self, app_config: AppConfiguration, force: bool = False): # Update the Connected to label on the popup menu. if app_config.server: self.connected_to_label.set_markup( - f'Connected to {app_config.server.name}') + f"Connected to {app_config.server.name}" + ) else: self.connected_to_label.set_markup( - f'Not Connected to a Server') + 'Not Connected to a Server' + ) self.stack.set_visible_child_name(app_config.state.current_tab) active_panel = self.stack.get_visible_child() - if hasattr(active_panel, 'update'): + if hasattr(active_panel, "update"): active_panel.update(app_config, force=force) self.player_controls.update(app_config) @@ -92,12 +87,10 @@ class MainWindow(Gtk.ApplicationWindow): stack = Gtk.Stack() for name, child in kwargs.items(): child.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) child.connect( - 'refresh-window', - lambda _, *args: self.emit('refresh-window', *args), + "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) stack.add_titled(child, name.lower(), name) return stack @@ -108,20 +101,17 @@ class MainWindow(Gtk.ApplicationWindow): """ header = Gtk.HeaderBar() header.set_show_close_button(True) - header.props.title = 'Sublime Music' + header.props.title = "Sublime Music" # Search - self.search_entry = Gtk.SearchEntry( - placeholder_text='Search everything...') + self.search_entry = Gtk.SearchEntry(placeholder_text="Search everything...") + self.search_entry.connect("focus-in-event", self._on_search_entry_focus) self.search_entry.connect( - 'focus-in-event', self._on_search_entry_focus) - self.search_entry.connect( - 'button-press-event', self._on_search_entry_button_press) - self.search_entry.connect( - 'focus-out-event', self._on_search_entry_loose_focus) - self.search_entry.connect('changed', self._on_search_entry_changed) - self.search_entry.connect( - 'stop-search', self._on_search_entry_stop_search) + "button-press-event", self._on_search_entry_button_press + ) + self.search_entry.connect("focus-out-event", self._on_search_entry_loose_focus) + self.search_entry.connect("changed", self._on_search_entry_changed) + self.search_entry.connect("stop-search", self._on_search_entry_stop_search) header.pack_start(self.search_entry) # Search popup @@ -133,13 +123,13 @@ class MainWindow(Gtk.ApplicationWindow): # Menu button menu_button = Gtk.MenuButton() - menu_button.set_tooltip_text('Open application menu') + menu_button.set_tooltip_text("Open application menu") menu_button.set_use_popover(True) menu_button.set_popover(self._create_menu()) - menu_button.connect('clicked', self._on_menu_clicked) + menu_button.connect("clicked", self._on_menu_clicked) self.menu.set_relative_to(menu_button) - icon = Gio.ThemedIcon(name='open-menu-symbolic') + icon = Gio.ThemedIcon(name="open-menu-symbolic") image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON) menu_button.add(image) @@ -156,31 +146,28 @@ class MainWindow(Gtk.ApplicationWindow): **kwargs, ) label.set_markup(text) - label.get_style_context().add_class('search-result-row') + label.get_style_context().add_class("search-result-row") return label def _create_menu(self) -> Gtk.PopoverMenu: self.menu = Gtk.PopoverMenu() - self.connected_to_label = self._create_label( - '', name='connected-to-label') + self.connected_to_label = self._create_label("", name="connected-to-label") self.connected_to_label.set_markup( - f'Not Connected to a Server') + 'Not Connected to a Server' + ) menu_items = [ (None, self.connected_to_label), - ( - 'app.configure-servers', - Gtk.ModelButton(text='Configure Servers'), - ), - ('app.settings', Gtk.ModelButton(text='Settings')), + ("app.configure-servers", Gtk.ModelButton(text="Configure Servers"),), + ("app.settings", Gtk.ModelButton(text="Settings")), ] vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) for name, item in menu_items: if name: item.set_action_name(name) - item.get_style_context().add_class('menu-button') + item.get_style_context().add_class("menu-button") vbox.pack_start(item, False, True, 0) self.menu.add(vbox) @@ -190,36 +177,33 @@ class MainWindow(Gtk.ApplicationWindow): self.search_popup = Gtk.PopoverMenu(modal=False) results_scrollbox = Gtk.ScrolledWindow( - min_content_width=500, - min_content_height=750, + min_content_width=500, min_content_height=750, ) def make_search_result_header(text: str) -> Gtk.Label: label = self._create_label(text) - label.get_style_context().add_class('search-result-header') + label.get_style_context().add_class("search-result-header") return label search_results_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - name='search-results', + orientation=Gtk.Orientation.VERTICAL, name="search-results", ) - self.search_results_loading = Gtk.Spinner( - active=False, name='search-spinner') + self.search_results_loading = Gtk.Spinner(active=False, name="search-spinner") search_results_box.add(self.search_results_loading) - search_results_box.add(make_search_result_header('Songs')) + search_results_box.add(make_search_result_header("Songs")) self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) search_results_box.add(self.song_results) - search_results_box.add(make_search_result_header('Albums')) + search_results_box.add(make_search_result_header("Albums")) self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) search_results_box.add(self.album_results) - search_results_box.add(make_search_result_header('Artists')) + search_results_box.add(make_search_result_header("Artists")) self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) search_results_box.add(self.artist_results) - search_results_box.add(make_search_result_header('Playlists')) + search_results_box.add(make_search_result_header("Playlists")) self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) search_results_box.add(self.playlist_results) @@ -238,24 +222,20 @@ class MainWindow(Gtk.ApplicationWindow): # Event Listeners # ========================================================================= def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool: - if not self._event_in_widgets( - event, - self.search_entry, - self.search_popup, - ): + if not self._event_in_widgets(event, self.search_entry, self.search_popup,): self._hide_search() if not self._event_in_widgets( - event, - self.player_controls.device_button, - self.player_controls.device_popover, + event, + self.player_controls.device_button, + self.player_controls.device_popover, ): self.player_controls.device_popover.popdown() if not self._event_in_widgets( - event, - self.player_controls.play_queue_button, - self.player_controls.play_queue_popover, + event, + self.player_controls.play_queue_button, + self.player_controls.play_queue_popover, ): self.player_controls.play_queue_popover.popdown() @@ -294,8 +274,7 @@ class MainWindow(Gtk.ApplicationWindow): def create_search_callback(idx: int) -> Callable[..., Any]: def search_result_calback( - result: SearchResult, - is_last_in_batch: bool, + result: SearchResult, is_last_in_batch: bool, ): # Ignore slow returned searches. if idx < self.latest_returned_search_idx: @@ -316,7 +295,8 @@ class MainWindow(Gtk.ApplicationWindow): entry.get_text(), search_callback=create_search_callback(self.search_idx), before_download=lambda: self._set_search_loading(True), - )) + ) + ) self.search_idx += 1 @@ -348,25 +328,25 @@ class MainWindow(Gtk.ApplicationWindow): widget.remove(c) def _create_search_result_row( - self, - text: str, - action_name: str, - value: Any, - artwork_future: CacheManager.Result, + self, + text: str, + action_name: str, + value: Any, + artwork_future: CacheManager.Result, ) -> Gtk.Button: def on_search_row_button_press(*args): - if action_name == 'song': - goto_action_name, goto_id = 'album', value.albumId + if action_name == "song": + goto_action_name, goto_id = "album", value.albumId else: goto_action_name, goto_id = action_name, value.id - self.emit('go-to', goto_action_name, goto_id) + self.emit("go-to", goto_action_name, goto_id) self._hide_search() row = Gtk.Button(relief=Gtk.ReliefStyle.NONE) - row.connect('button-press-event', on_search_row_button_press) + row.connect("button-press-event", on_search_row_button_press) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - image = SpinnerImage(image_name='search-artwork', image_size=30) + image = SpinnerImage(image_name="search-artwork", image_size=30) box.add(image) box.add(self._create_label(text)) row.add(box) @@ -375,8 +355,7 @@ class MainWindow(Gtk.ApplicationWindow): image.set_loading(False) image.set_from_file(f.result()) - artwork_future.add_done_callback( - lambda f: GLib.idle_add(image_callback, f)) + artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f)) return row @@ -386,14 +365,14 @@ class MainWindow(Gtk.ApplicationWindow): self._remove_all_from_widget(self.song_results) for song in search_results.song or []: label_text = util.dot_join( - f'{util.esc(song.title)}', - util.esc(song.artist), + f"{util.esc(song.title)}", util.esc(song.artist), ) - cover_art_future = CacheManager.get_cover_art_filename( - song.coverArt) + cover_art_future = CacheManager.get_cover_art_filename(song.coverArt) self.song_results.add( self._create_search_result_row( - label_text, 'song', song, cover_art_future)) + label_text, "song", song, cover_art_future + ) + ) self.song_results.show_all() @@ -402,14 +381,14 @@ class MainWindow(Gtk.ApplicationWindow): self._remove_all_from_widget(self.album_results) for album in search_results.album or []: label_text = util.dot_join( - f'{util.esc(album.name)}', - util.esc(album.artist), + f"{util.esc(album.name)}", util.esc(album.artist), ) - cover_art_future = CacheManager.get_cover_art_filename( - album.coverArt) + cover_art_future = CacheManager.get_cover_art_filename(album.coverArt) self.album_results.add( self._create_search_result_row( - label_text, 'album', album, cover_art_future)) + label_text, "album", album, cover_art_future + ) + ) self.album_results.show_all() @@ -421,7 +400,9 @@ class MainWindow(Gtk.ApplicationWindow): cover_art_future = CacheManager.get_artist_artwork(artist) self.artist_results.add( self._create_search_result_row( - label_text, 'artist', artist, cover_art_future)) + label_text, "artist", artist, cover_art_future + ) + ) self.artist_results.show_all() @@ -431,10 +412,13 @@ class MainWindow(Gtk.ApplicationWindow): for playlist in search_results.playlist or []: label_text = util.esc(playlist.name) cover_art_future = CacheManager.get_cover_art_filename( - playlist.coverArt) + playlist.coverArt + ) self.playlist_results.add( self._create_search_result_row( - label_text, 'playlist', playlist, cover_art_future)) + label_text, "playlist", playlist, cover_art_future + ) + ) self.playlist_results.show_all() @@ -451,8 +435,9 @@ class MainWindow(Gtk.ApplicationWindow): bound_y = (win_y + widget_y, win_y + widget_y + allocation.height) # If the event is in this widget, return True immediately. - if ((bound_x[0] <= event.x_root <= bound_x[1]) - and (bound_y[0] <= event.y_root <= bound_y[1])): + if (bound_x[0] <= event.x_root <= bound_x[1]) and ( + bound_y[0] <= event.y_root <= bound_y[1] + ): return True return False diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index aafa409..717426c 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -5,7 +5,8 @@ from pathlib import Path from typing import Any, Callable, List, Optional import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango from pychromecast import Chromecast @@ -22,33 +23,18 @@ class PlayerControls(Gtk.ActionBar): """ Defines the player controls panel that appears at the bottom of the window. """ + __gsignals__ = { - 'song-scrub': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (float, ), - ), - 'volume-change': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (float, ), - ), - 'device-update': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (str, ), - ), - 'song-clicked': ( + "song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),), + "volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),), + "device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,),), + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'songs-removed': ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (object, ), - ), - 'refresh-window': ( + "songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),), + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -66,7 +52,7 @@ class PlayerControls(Gtk.ActionBar): def __init__(self): Gtk.ActionBar.__init__(self) - self.set_name('player-controls-bar') + self.set_name("player-controls-bar") song_display = self.create_song_display() playback_controls = self.create_playback_controls() @@ -83,34 +69,40 @@ class PlayerControls(Gtk.ActionBar): duration = ( app_config.state.current_song.duration - if app_config.state.current_song else None) + if app_config.state.current_song + else None + ) self.update_scrubber(app_config.state.song_progress, duration) - icon = 'pause' if app_config.state.playing else 'start' + icon = "pause" if app_config.state.playing else "start" self.play_button.set_icon(f"media-playback-{icon}-symbolic") self.play_button.set_tooltip_text( - 'Pause' if app_config.state.playing else 'Play') + "Pause" if app_config.state.playing else "Play" + ) has_current_song = app_config.state.current_song is not None has_next_song = False - if app_config.state.repeat_type in (RepeatType.REPEAT_QUEUE, - RepeatType.REPEAT_SONG): + if app_config.state.repeat_type in ( + RepeatType.REPEAT_QUEUE, + RepeatType.REPEAT_SONG, + ): has_next_song = True elif has_current_song: last_idx_in_queue = len(app_config.state.play_queue) - 1 - has_next_song = ( - app_config.state.current_song_index < last_idx_in_queue) + has_next_song = app_config.state.current_song_index < last_idx_in_queue # Toggle button states. self.repeat_button.set_action_name(None) self.shuffle_button.set_action_name(None) repeat_on = app_config.state.repeat_type in ( - RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG) + RepeatType.REPEAT_QUEUE, + RepeatType.REPEAT_SONG, + ) self.repeat_button.set_active(repeat_on) self.repeat_button.set_icon(app_config.state.repeat_type.icon) self.shuffle_button.set_active(app_config.state.shuffle_on) - self.repeat_button.set_action_name('app.repeat-press') - self.shuffle_button.set_action_name('app.shuffle-press') + self.repeat_button.set_action_name("app.repeat-press") + self.shuffle_button.set_action_name("app.shuffle-press") self.song_scrubber.set_sensitive(has_current_song) self.prev_button.set_sensitive(has_current_song) @@ -119,19 +111,20 @@ class PlayerControls(Gtk.ActionBar): # Volume button and slider if app_config.state.is_muted: - icon_name = 'muted' + icon_name = "muted" elif app_config.state.volume < 30: - icon_name = 'low' + icon_name = "low" elif app_config.state.volume < 70: - icon_name = 'medium' + icon_name = "medium" else: - icon_name = 'high' + icon_name = "high" - self.volume_mute_toggle.set_icon(f'audio-volume-{icon_name}-symbolic') + self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic") self.editing = True self.volume_slider.set_value( - 0 if app_config.state.is_muted else app_config.state.volume) + 0 if app_config.state.is_muted else app_config.state.volume + ) self.editing = False # Update the current song information. @@ -143,19 +136,17 @@ class PlayerControls(Gtk.ActionBar): order_token=self.cover_art_update_order_token, ) - self.song_title.set_markup( - util.esc(app_config.state.current_song.title)) - self.album_name.set_markup( - util.esc(app_config.state.current_song.album)) + self.song_title.set_markup(util.esc(app_config.state.current_song.title)) + self.album_name.set_markup(util.esc(app_config.state.current_song.album)) artist_name = util.esc(app_config.state.current_song.artist) - self.artist_name.set_markup(artist_name or '') + self.artist_name.set_markup(artist_name or "") else: # Clear out the cover art and song tite if no song self.album_art.set_from_file(None) self.album_art.set_loading(False) - self.song_title.set_markup('') - self.album_name.set_markup('') - self.artist_name.set_markup('') + self.song_title.set_markup("") + self.album_name.set_markup("") + self.artist_name.set_markup("") if self.devices_requested: self.update_device_list() @@ -163,11 +154,12 @@ class PlayerControls(Gtk.ActionBar): # Set the Play Queue button popup. play_queue_len = len(app_config.state.play_queue) if play_queue_len == 0: - self.popover_label.set_markup('Play Queue') + self.popover_label.set_markup("Play Queue") else: - song_label = util.pluralize('song', play_queue_len) + song_label = util.pluralize("song", play_queue_len) self.popover_label.set_markup( - f'Play Queue: {play_queue_len} {song_label}') + f"Play Queue: {play_queue_len} {song_label}" + ) self.editing_play_queue_song_list = True @@ -177,19 +169,15 @@ class PlayerControls(Gtk.ActionBar): title = util.esc(song_details.title) album = util.esc(song_details.album) artist = util.esc(song_details.artist) - return f'{title}\n{util.dot_join(album, artist)}' + return f"{title}\n{util.dot_join(album, artist)}" def make_idle_index_capturing_function( - idx: int, - order_tok: int, - fn: Callable[[int, int, Any], None], + idx: int, order_tok: int, fn: Callable[[int, int, Any], None], ) -> Callable[[CacheManager.Result], None]: return lambda f: GLib.idle_add(fn, idx, order_tok, f.result()) def on_cover_art_future_done( - idx: int, - order_token: int, - cover_art_filename: str, + idx: int, order_token: int, cover_art_filename: str, ): if order_token != self.play_queue_update_order_token: return @@ -197,9 +185,7 @@ class PlayerControls(Gtk.ActionBar): self.play_queue_store[idx][0] = cover_art_filename def on_song_details_future_done( - idx: int, - order_token: int, - song_details: Child, + idx: int, order_token: int, song_details: Child, ): if order_token != self.play_queue_update_order_token: return @@ -208,15 +194,15 @@ class PlayerControls(Gtk.ActionBar): # Cover Art cover_art_result = CacheManager.get_cover_art_filename( - song_details.coverArt) + song_details.coverArt + ) if cover_art_result.is_future: # We don't have the cover art already cached. cover_art_result.add_done_callback( make_idle_index_capturing_function( - idx, - order_token, - on_cover_art_future_done, - )) + idx, order_token, on_cover_art_future_done, + ) + ) else: # We have the cover art already cached. self.play_queue_store[idx][0] = cover_art_result.result() @@ -229,8 +215,8 @@ class PlayerControls(Gtk.ActionBar): for i, song_id in enumerate(app_config.state.play_queue): song_details_result = CacheManager.get_song_details(song_id) - cover_art_filename = '' - label = '\n' + cover_art_filename = "" + label = "\n" if song_details_result.is_future: song_details_results.append((i, song_details_result)) @@ -240,7 +226,8 @@ class PlayerControls(Gtk.ActionBar): label = calculate_label(song_details) cover_art_result = CacheManager.get_cover_art_filename( - song_details.coverArt) + song_details.coverArt + ) if cover_art_result.is_future: # We don't have the cover art already cached. cover_art_result.add_done_callback( @@ -248,7 +235,8 @@ class PlayerControls(Gtk.ActionBar): i, self.play_queue_update_order_token, on_cover_art_future_done, - )) + ) + ) else: # We have the cover art already cached. cover_art_filename = cover_art_result.result() @@ -259,7 +247,8 @@ class PlayerControls(Gtk.ActionBar): label, i == app_config.state.current_song_index, song_id, - ]) + ] + ) util.diff_song_store(self.play_queue_store, new_store) @@ -270,7 +259,8 @@ class PlayerControls(Gtk.ActionBar): idx, self.play_queue_update_order_token, on_song_details_future_done, - )) + ) + ) self.editing_play_queue_song_list = False @@ -293,13 +283,11 @@ class PlayerControls(Gtk.ActionBar): self.album_art.set_loading(False) def update_scrubber( - self, - current: Optional[float], - duration: Optional[int], + self, current: Optional[float], duration: Optional[int], ): if current is None or duration is None: - self.song_duration_label.set_text('-:--') - self.song_progress_label.set_text('-:--') + self.song_duration_label.set_text("-:--") + self.song_progress_label.set_text("-:--") self.song_scrubber.set_value(0) return @@ -310,11 +298,12 @@ class PlayerControls(Gtk.ActionBar): self.song_scrubber.set_value(percent_complete) self.song_duration_label.set_text(util.format_song_duration(duration)) self.song_progress_label.set_text( - util.format_song_duration(math.floor(current))) + util.format_song_duration(math.floor(current)) + ) def on_volume_change(self, scale: Gtk.Scale): if not self.editing: - self.emit('volume-change', scale.get_value()) + self.emit("volume-change", scale.get_value()) def on_play_queue_click(self, _: Any): if self.play_queue_popover.is_visible(): @@ -327,10 +316,10 @@ class PlayerControls(Gtk.ActionBar): def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any): # The song ID is in the last column of the model. self.emit( - 'song-clicked', + "song-clicked", idx.get_indices()[0], [m[-1] for m in self.play_queue_store], - {'no_reshuffle': True}, + {"no_reshuffle": True}, ) def update_device_list(self, force: bool = False): @@ -341,21 +330,23 @@ class PlayerControls(Gtk.ActionBar): for c in self.chromecast_device_list.get_children(): self.chromecast_device_list.remove(c) - if self.current_device == 'this device': - self.this_device.set_icon('audio-volume-high-symbolic') + if self.current_device == "this device": + self.this_device.set_icon("audio-volume-high-symbolic") else: self.this_device.set_icon(None) chromecasts.sort(key=lambda c: c.device.friendly_name) for cc in chromecasts: icon = ( - 'audio-volume-high-symbolic' - if str(cc.device.uuid) == self.current_device else None) + "audio-volume-high-symbolic" + if str(cc.device.uuid) == self.current_device + else None + ) btn = IconButton(icon, label=cc.device.friendly_name) - btn.get_style_context().add_class('menu-button') + btn.get_style_context().add_class("menu-button") btn.connect( - 'clicked', - lambda _, uuid: self.emit('device-update', uuid), + "clicked", + lambda _, uuid: self.emit("device-update", uuid), cc.device.uuid, ) self.chromecast_device_list.add(btn) @@ -366,12 +357,13 @@ class PlayerControls(Gtk.ActionBar): update_diff = ( self.last_device_list_update - and (datetime.now() - self.last_device_list_update).seconds > 60) - if (force or len(self.chromecasts) == 0 - or (update_diff and update_diff > 60)): + and (datetime.now() - self.last_device_list_update).seconds > 60 + ) + if force or len(self.chromecasts) == 0 or (update_diff and update_diff > 60): future = ChromecastPlayer.get_chromecasts() future.add_done_callback( - lambda f: GLib.idle_add(chromecast_callback, f.result())) + lambda f: GLib.idle_add(chromecast_callback, f.result()) + ) else: chromecast_callback(self.chromecasts) @@ -387,11 +379,7 @@ class PlayerControls(Gtk.ActionBar): def on_device_refresh_click(self, _: Any): self.update_device_list(force=True) - def on_play_queue_button_press( - self, - tree: Any, - event: Gdk.EventButton, - ) -> bool: + def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton,) -> bool: if event.button == 3: # Right click clicked_path = tree.get_path_at_pos(event.x, event.y) @@ -401,7 +389,7 @@ class PlayerControls(Gtk.ActionBar): def on_download_state_change(): # Refresh the entire window (no force) because the song could # be in a list anywhere in the window. - self.emit('refresh-window', {}, False) + self.emit("refresh-window", {}, False) # Use the new selection instead of the old one for calculating what # to do the right click on. @@ -412,11 +400,11 @@ class PlayerControls(Gtk.ActionBar): song_ids = [self.play_queue_store[p][-1] for p in paths] remove_text = ( - 'Remove ' + util.pluralize('song', len(song_ids)) - + ' from queue') + "Remove " + util.pluralize("song", len(song_ids)) + " from queue" + ) def on_remove_songs_click(_: Any): - self.emit('songs-removed', [p.get_indices()[0] for p in paths]) + self.emit("songs-removed", [p.get_indices()[0] for p in paths]) util.show_song_popover( song_ids, @@ -448,10 +436,10 @@ class PlayerControls(Gtk.ActionBar): i for i, s in enumerate(self.play_queue_store) if s[2] ][0] self.emit( - 'refresh-window', + "refresh-window", { - 'current_song_index': currently_playing_index, - 'play_queue': [s[-1] for s in self.play_queue_store], + "current_song_index": currently_playing_index, + "play_queue": [s[-1] for s in self.play_queue_store], }, False, ) @@ -463,8 +451,7 @@ class PlayerControls(Gtk.ActionBar): box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.album_art = SpinnerImage( - image_name='player-controls-album-artwork', - image_size=70, + image_name="player-controls-album-artwork", image_size=70, ) box.pack_start(self.album_art, False, False, 5) @@ -480,13 +467,13 @@ class PlayerControls(Gtk.ActionBar): ellipsize=Pango.EllipsizeMode.END, ) - self.song_title = make_label('song-title') + self.song_title = make_label("song-title") details_box.add(self.song_title) - self.album_name = make_label('album-name') + self.album_name = make_label("album-name") details_box.add(self.album_name) - self.artist_name = make_label('artist-name') + self.artist_name = make_label("artist-name") details_box.add(self.artist_name) details_box.pack_start(Gtk.Box(), True, True, 0) @@ -500,18 +487,20 @@ class PlayerControls(Gtk.ActionBar): # Scrubber and song progress/length labels scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - self.song_progress_label = Gtk.Label(label='-:--') + self.song_progress_label = Gtk.Label(label="-:--") scrubber_box.pack_start(self.song_progress_label, False, False, 5) self.song_scrubber = Gtk.Scale.new_with_range( - orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5) - self.song_scrubber.set_name('song-scrubber') + orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5 + ) + self.song_scrubber.set_name("song-scrubber") self.song_scrubber.set_draw_value(False) self.song_scrubber.connect( - 'change-value', lambda s, t, v: self.emit('song-scrub', v)) + "change-value", lambda s, t, v: self.emit("song-scrub", v) + ) scrubber_box.pack_start(self.song_scrubber, True, True, 0) - self.song_duration_label = Gtk.Label(label='-:--') + self.song_duration_label = Gtk.Label(label="-:--") scrubber_box.pack_start(self.song_duration_label, False, False, 5) box.add(scrubber_box) @@ -522,8 +511,9 @@ class PlayerControls(Gtk.ActionBar): # Repeat button repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.repeat_button = IconToggleButton( - 'media-playlist-repeat', 'Switch between repeat modes') - self.repeat_button.set_action_name('app.repeat-press') + "media-playlist-repeat", "Switch between repeat modes" + ) + self.repeat_button.set_action_name("app.repeat-press") repeat_button_box.pack_start(Gtk.Box(), True, True, 0) repeat_button_box.pack_start(self.repeat_button, False, False, 0) repeat_button_box.pack_start(Gtk.Box(), True, True, 0) @@ -531,35 +521,39 @@ class PlayerControls(Gtk.ActionBar): # Previous button self.prev_button = IconButton( - 'media-skip-backward-symbolic', - 'Go to previous song', - icon_size=Gtk.IconSize.LARGE_TOOLBAR) - self.prev_button.set_action_name('app.prev-track') + "media-skip-backward-symbolic", + "Go to previous song", + icon_size=Gtk.IconSize.LARGE_TOOLBAR, + ) + self.prev_button.set_action_name("app.prev-track") buttons.pack_start(self.prev_button, False, False, 5) # Play button self.play_button = IconButton( - 'media-playback-start-symbolic', - 'Play', + "media-playback-start-symbolic", + "Play", relief=True, - icon_size=Gtk.IconSize.LARGE_TOOLBAR) - self.play_button.set_name('play-button') - self.play_button.set_action_name('app.play-pause') + icon_size=Gtk.IconSize.LARGE_TOOLBAR, + ) + self.play_button.set_name("play-button") + self.play_button.set_action_name("app.play-pause") buttons.pack_start(self.play_button, False, False, 0) # Next button self.next_button = IconButton( - 'media-skip-forward-symbolic', - 'Go to next song', - icon_size=Gtk.IconSize.LARGE_TOOLBAR) - self.next_button.set_action_name('app.next-track') + "media-skip-forward-symbolic", + "Go to next song", + icon_size=Gtk.IconSize.LARGE_TOOLBAR, + ) + self.next_button.set_action_name("app.next-track") buttons.pack_start(self.next_button, False, False, 5) # Shuffle button shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.shuffle_button = IconToggleButton( - 'media-playlist-shuffle-symbolic', 'Toggle playlist shuffling') - self.shuffle_button.set_action_name('app.shuffle-press') + "media-playlist-shuffle-symbolic", "Toggle playlist shuffling" + ) + self.shuffle_button.set_action_name("app.shuffle-press") shuffle_button_box.pack_start(Gtk.Box(), True, True, 0) shuffle_button_box.pack_start(self.shuffle_button, False, False, 0) shuffle_button_box.pack_start(Gtk.Box(), True, True, 0) @@ -577,36 +571,28 @@ class PlayerControls(Gtk.ActionBar): # Device button (for chromecast) self.device_button = IconButton( - 'video-display-symbolic', - 'Show available audio output devices', + "video-display-symbolic", + "Show available audio output devices", icon_size=Gtk.IconSize.LARGE_TOOLBAR, ) - self.device_button.connect('clicked', self.on_device_click) + self.device_button.connect("clicked", self.on_device_click) box.pack_start(self.device_button, False, True, 5) - self.device_popover = Gtk.PopoverMenu( - modal=False, - name='device-popover', - ) + self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover",) self.device_popover.set_relative_to(self.device_button) device_popover_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - name='device-popover-box', + orientation=Gtk.Orientation.VERTICAL, name="device-popover-box", ) device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.popover_label = Gtk.Label( - label='Devices', - use_markup=True, - halign=Gtk.Align.START, - margin=5, + label="Devices", use_markup=True, halign=Gtk.Align.START, margin=5, ) device_popover_header.add(self.popover_label) - refresh_devices = IconButton( - 'view-refresh-symbolic', 'Refresh device list') - refresh_devices.connect('clicked', self.on_device_refresh_click) + refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list") + refresh_devices.connect("clicked", self.on_device_refresh_click) device_popover_header.pack_end(refresh_devices, False, False, 0) device_popover_box.add(device_popover_header) @@ -614,22 +600,21 @@ class PlayerControls(Gtk.ActionBar): device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.this_device = IconButton( - 'audio-volume-high-symbolic', - label='This Device', + "audio-volume-high-symbolic", label="This Device", ) - self.this_device.get_style_context().add_class('menu-button') + self.this_device.get_style_context().add_class("menu-button") self.this_device.connect( - 'clicked', lambda *a: self.emit('device-update', 'this device')) + "clicked", lambda *a: self.emit("device-update", "this device") + ) device_list.add(self.this_device) device_list.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) self.device_list_loading = Gtk.Spinner(active=True) - self.device_list_loading.get_style_context().add_class('menu-button') + self.device_list_loading.get_style_context().add_class("menu-button") device_list.add(self.device_list_loading) - self.chromecast_device_list = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL) + self.chromecast_device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) device_list.add(self.chromecast_device_list) device_popover_box.pack_end(device_list, True, True, 0) @@ -638,25 +623,21 @@ class PlayerControls(Gtk.ActionBar): # Play Queue button self.play_queue_button = IconButton( - 'view-list-symbolic', - 'Open play queue', + "view-list-symbolic", + "Open play queue", icon_size=Gtk.IconSize.LARGE_TOOLBAR, ) - self.play_queue_button.connect('clicked', self.on_play_queue_click) + self.play_queue_button.connect("clicked", self.on_play_queue_click) box.pack_start(self.play_queue_button, False, True, 5) - self.play_queue_popover = Gtk.PopoverMenu( - modal=False, - name='up-next-popover', - ) + self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover",) self.play_queue_popover.set_relative_to(self.play_queue_button) play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - play_queue_popover_header = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL) + play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.popover_label = Gtk.Label( - label='Play Queue', + label="Play Queue", use_markup=True, halign=Gtk.Align.START, margin=10, @@ -664,15 +645,15 @@ class PlayerControls(Gtk.ActionBar): play_queue_popover_header.add(self.popover_label) load_play_queue = IconButton( - 'folder-download-symbolic', 'Load Queue from Server', margin=5) - load_play_queue.set_action_name('app.update-play-queue-from-server') + "folder-download-symbolic", "Load Queue from Server", margin=5 + ) + load_play_queue.set_action_name("app.update-play-queue-from-server") play_queue_popover_header.pack_end(load_play_queue, False, False, 0) play_queue_popover_box.add(play_queue_popover_header) play_queue_scrollbox = Gtk.ScrolledWindow( - min_content_height=600, - min_content_width=400, + min_content_height=600, min_content_width=400, ) self.play_queue_store = Gtk.ListStore( @@ -682,12 +663,9 @@ class PlayerControls(Gtk.ActionBar): str, # song ID ) self.play_queue_list = Gtk.TreeView( - model=self.play_queue_store, - reorderable=True, - headers_visible=False, + model=self.play_queue_store, reorderable=True, headers_visible=False, ) - self.play_queue_list.get_selection().set_mode( - Gtk.SelectionMode.MULTIPLE) + self.play_queue_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) # Album Art column. def filename_to_pixbuf( @@ -699,48 +677,42 @@ class PlayerControls(Gtk.ActionBar): ): filename = model.get_value(iter, 0) if not filename: - cell.set_property('icon_name', '') + cell.set_property("icon_name", "") return - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - filename, 50, 50, True) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True) # If this is the playing song, then overlay the play icon. if model.get_value(iter, 2): play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file( - str( - Path(__file__).parent.joinpath( - 'images/play-queue-play.png'))) + str(Path(__file__).parent.joinpath("images/play-queue-play.png")) + ) play_overlay_pixbuf.composite( - pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, - GdkPixbuf.InterpType.NEAREST, 255) + pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 255 + ) - cell.set_property('pixbuf', pixbuf) + cell.set_property("pixbuf", pixbuf) renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(55, 60) - column = Gtk.TreeViewColumn('', renderer) + column = Gtk.TreeViewColumn("", renderer) column.set_cell_data_func(renderer, filename_to_pixbuf) column.set_resizable(True) self.play_queue_list.append_column(column) - renderer = Gtk.CellRendererText( - markup=True, - ellipsize=Pango.EllipsizeMode.END, - ) - column = Gtk.TreeViewColumn('', renderer, markup=1) + renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END,) + column = Gtk.TreeViewColumn("", renderer, markup=1) self.play_queue_list.append_column(column) - self.play_queue_list.connect('row-activated', self.on_song_activated) + self.play_queue_list.connect("row-activated", self.on_song_activated) self.play_queue_list.connect( - 'button-press-event', self.on_play_queue_button_press) + "button-press-event", self.on_play_queue_button_press + ) # Set up drag-and-drop on the song list for editing the order of the # playlist. - self.play_queue_store.connect( - 'row-inserted', self.on_play_queue_model_row_move) - self.play_queue_store.connect( - 'row-deleted', self.on_play_queue_model_row_move) + self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move) + self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move) play_queue_scrollbox.add(self.play_queue_list) play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0) @@ -749,16 +721,18 @@ class PlayerControls(Gtk.ActionBar): # Volume mute toggle self.volume_mute_toggle = IconButton( - 'audio-volume-high-symbolic', 'Toggle mute') - self.volume_mute_toggle.set_action_name('app.mute-toggle') + "audio-volume-high-symbolic", "Toggle mute" + ) + self.volume_mute_toggle.set_action_name("app.mute-toggle") box.pack_start(self.volume_mute_toggle, False, True, 0) # Volume slider self.volume_slider = Gtk.Scale.new_with_range( - orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5) - self.volume_slider.set_name('volume-slider') + orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5 + ) + self.volume_slider.set_name("volume-slider") self.volume_slider.set_draw_value(False) - self.volume_slider.connect('value-changed', self.on_volume_change) + self.volume_slider.connect("value-changed", self.on_volume_change) box.pack_start(self.volume_slider, True, True, 0) vbox.pack_start(box, False, True, 0) diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 4ca36d8..dca4987 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -3,7 +3,8 @@ from random import randint from typing import Any, Iterable, List, Tuple import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from fuzzywuzzy import process from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango @@ -21,26 +22,27 @@ from sublime.ui.common import ( class EditPlaylistDialog(EditFormDialog): - entity_name: str = 'Playlist' + entity_name: str = "Playlist" initial_size = (350, 120) - text_fields = [('Name', 'name', False), ('Comment', 'comment', False)] - boolean_fields = [('Public', 'public')] + text_fields = [("Name", "name", False), ("Comment", "comment", False)] + boolean_fields = [("Public", "public")] def __init__(self, *args, **kwargs): - delete_playlist = Gtk.Button(label='Delete Playlist') + delete_playlist = Gtk.Button(label="Delete Playlist") self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)] super().__init__(*args, **kwargs) class PlaylistsPanel(Gtk.Paned): """Defines the playlists panel.""" + __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -55,12 +57,10 @@ class PlaylistsPanel(Gtk.Paned): self.playlist_detail_panel = PlaylistDetailPanel() self.playlist_detail_panel.connect( - 'song-clicked', - lambda _, *args: self.emit('song-clicked', *args), + "song-clicked", lambda _, *args: self.emit("song-clicked", *args), ) self.playlist_detail_panel.connect( - 'refresh-window', - lambda _, *args: self.emit('refresh-window', *args), + "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) self.pack2(self.playlist_detail_panel, True, False) @@ -71,7 +71,7 @@ class PlaylistsPanel(Gtk.Paned): class PlaylistList(Gtk.Box): __gsignals__ = { - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -92,57 +92,50 @@ class PlaylistList(Gtk.Box): playlist_list_actions = Gtk.ActionBar() - new_playlist_button = IconButton( - 'list-add-symbolic', label='New Playlist') - new_playlist_button.connect('clicked', self.on_new_playlist_clicked) + new_playlist_button = IconButton("list-add-symbolic", label="New Playlist") + new_playlist_button.connect("clicked", self.on_new_playlist_clicked) playlist_list_actions.pack_start(new_playlist_button) list_refresh_button = IconButton( - 'view-refresh-symbolic', 'Refresh list of playlists') - list_refresh_button.connect('clicked', self.on_list_refresh_click) + "view-refresh-symbolic", "Refresh list of playlists" + ) + list_refresh_button.connect("clicked", self.on_list_refresh_click) playlist_list_actions.pack_end(list_refresh_button) self.add(playlist_list_actions) loading_new_playlist = Gtk.ListBox() - self.loading_indicator = Gtk.ListBoxRow( - activatable=False, - selectable=False, - ) - loading_spinner = Gtk.Spinner( - name='playlist-list-spinner', active=True) + self.loading_indicator = Gtk.ListBoxRow(activatable=False, selectable=False,) + loading_spinner = Gtk.Spinner(name="playlist-list-spinner", active=True) self.loading_indicator.add(loading_spinner) loading_new_playlist.add(self.loading_indicator) - self.new_playlist_row = Gtk.ListBoxRow( - activatable=False, selectable=False) - new_playlist_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, visible=False) + self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False) + new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False) - self.new_playlist_entry = Gtk.Entry( - name='playlist-list-new-playlist-entry') - self.new_playlist_entry.connect('activate', self.new_entry_activate) + self.new_playlist_entry = Gtk.Entry(name="playlist-list-new-playlist-entry") + self.new_playlist_entry.connect("activate", self.new_entry_activate) new_playlist_box.add(self.new_playlist_entry) new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) confirm_button = IconButton( - 'object-select-symbolic', - 'Create playlist', - name='playlist-list-new-playlist-confirm', + "object-select-symbolic", + "Create playlist", + name="playlist-list-new-playlist-confirm", relief=True, ) - confirm_button.connect('clicked', self.confirm_button_clicked) + confirm_button.connect("clicked", self.confirm_button_clicked) new_playlist_actions.pack_end(confirm_button, False, True, 0) self.cancel_button = IconButton( - 'process-stop-symbolic', - 'Cancel create playlist', - name='playlist-list-new-playlist-cancel', + "process-stop-symbolic", + "Cancel create playlist", + name="playlist-list-new-playlist-cancel", relief=True, ) - self.cancel_button.connect('clicked', self.cancel_button_clicked) + self.cancel_button.connect("clicked", self.cancel_button_clicked) new_playlist_actions.pack_end(self.cancel_button, False, True, 0) new_playlist_box.add(new_playlist_actions) @@ -153,26 +146,26 @@ class PlaylistList(Gtk.Box): list_scroll_window = Gtk.ScrolledWindow(min_content_width=220) - def create_playlist_row( - model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow: + def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow: row = Gtk.ListBoxRow( - action_name='app.go-to-playlist', - action_target=GLib.Variant('s', model.playlist_id), + action_name="app.go-to-playlist", + action_target=GLib.Variant("s", model.playlist_id), ) row.add( Gtk.Label( - label=f'{model.name}', + label=f"{model.name}", use_markup=True, margin=10, halign=Gtk.Align.START, ellipsize=Pango.EllipsizeMode.END, max_width_chars=30, - )) + ) + ) row.show_all() return row self.playlists_store = Gio.ListStore() - self.list = Gtk.ListBox(name='playlist-list-listbox') + self.list = Gtk.ListBox(name="playlist-list-listbox") self.list.bind_model(self.playlists_store, create_playlist_row) list_scroll_window.add(self.list) self.pack_start(list_scroll_window, True, True, 0) @@ -196,12 +189,14 @@ class PlaylistList(Gtk.Box): new_store = [] selected_idx = None for i, playlist in enumerate(playlists or []): - if (app_config and app_config.state - and app_config.state.selected_playlist_id == playlist.id): + if ( + app_config + and app_config.state + and app_config.state.selected_playlist_id == playlist.id + ): selected_idx = i - new_store.append( - PlaylistList.PlaylistModel(playlist.id, playlist.name)) + new_store.append(PlaylistList.PlaylistModel(playlist.id, playlist.name)) util.diff_model_store(self.playlists_store, new_store) @@ -215,7 +210,7 @@ class PlaylistList(Gtk.Box): # Event Handlers # ========================================================================= def on_new_playlist_clicked(self, _: Any): - self.new_playlist_entry.set_text('Untitled Playlist') + self.new_playlist_entry.set_text("Untitled Playlist") self.new_playlist_entry.grab_focus() self.new_playlist_row.show() @@ -237,20 +232,20 @@ class PlaylistList(Gtk.Box): self.update(force=True) self.loading_indicator.show() - playlist_ceate_future = CacheManager.create_playlist( - name=playlist_name) + playlist_ceate_future = CacheManager.create_playlist(name=playlist_name) playlist_ceate_future.add_done_callback( - lambda f: GLib.idle_add(on_playlist_created, f)) + lambda f: GLib.idle_add(on_playlist_created, f) + ) class PlaylistDetailPanel(Gtk.Overlay): __gsignals__ = { - 'song-clicked': ( + "song-clicked": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int, object, object), ), - 'refresh-window': ( + "refresh-window": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, bool), @@ -263,19 +258,18 @@ class PlaylistDetailPanel(Gtk.Overlay): reordering_playlist_song_list: bool = False def __init__(self): - Gtk.Overlay.__init__(self, name='playlist-view-overlay') + Gtk.Overlay.__init__(self, name="playlist-view-overlay") playlist_view_scroll_window = Gtk.ScrolledWindow() playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Playlist info panel self.big_info_panel = Gtk.Box( - name='playlist-info-panel', - orientation=Gtk.Orientation.HORIZONTAL, + name="playlist-info-panel", orientation=Gtk.Orientation.HORIZONTAL, ) self.playlist_artwork = SpinnerImage( - image_name='playlist-album-artwork', - spinner_name='playlist-artwork-spinner', + image_name="playlist-album-artwork", + spinner_name="playlist-artwork-spinner", image_size=200, ) self.big_info_panel.pack_start(self.playlist_artwork, False, False, 0) @@ -285,65 +279,57 @@ class PlaylistDetailPanel(Gtk.Overlay): # Action buttons (note we are packing end here, so we have to put them # in right-to-left). - self.playlist_action_buttons = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL) + self.playlist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) view_refresh_button = IconButton( - 'view-refresh-symbolic', 'Refresh playlist info') - view_refresh_button.connect('clicked', self.on_view_refresh_click) - self.playlist_action_buttons.pack_end( - view_refresh_button, False, False, 5) + "view-refresh-symbolic", "Refresh playlist info" + ) + view_refresh_button.connect("clicked", self.on_view_refresh_click) + self.playlist_action_buttons.pack_end(view_refresh_button, False, False, 5) - playlist_edit_button = IconButton( - 'document-edit-symbolic', 'Edit paylist') - playlist_edit_button.connect( - 'clicked', self.on_playlist_edit_button_click) - self.playlist_action_buttons.pack_end( - playlist_edit_button, False, False, 5) + playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist") + playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click) + self.playlist_action_buttons.pack_end(playlist_edit_button, False, False, 5) download_all_button = IconButton( - 'folder-download-symbolic', 'Download all songs in the playlist') + "folder-download-symbolic", "Download all songs in the playlist" + ) download_all_button.connect( - 'clicked', self.on_playlist_list_download_all_button_click) - self.playlist_action_buttons.pack_end( - download_all_button, False, False, 5) + "clicked", self.on_playlist_list_download_all_button_click + ) + self.playlist_action_buttons.pack_end(download_all_button, False, False, 5) - playlist_details_box.pack_start( - self.playlist_action_buttons, False, False, 5) + playlist_details_box.pack_start(self.playlist_action_buttons, False, False, 5) playlist_details_box.pack_start(Gtk.Box(), True, False, 0) - self.playlist_indicator = self.make_label(name='playlist-indicator') + self.playlist_indicator = self.make_label(name="playlist-indicator") playlist_details_box.add(self.playlist_indicator) - self.playlist_name = self.make_label(name='playlist-name') + self.playlist_name = self.make_label(name="playlist-name") playlist_details_box.add(self.playlist_name) - self.playlist_comment = self.make_label(name='playlist-comment') + self.playlist_comment = self.make_label(name="playlist-comment") playlist_details_box.add(self.playlist_comment) - self.playlist_stats = self.make_label(name='playlist-stats') + self.playlist_stats = self.make_label(name="playlist-stats") playlist_details_box.add(self.playlist_stats) self.play_shuffle_buttons = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - name='playlist-play-shuffle-buttons', + name="playlist-play-shuffle-buttons", ) play_button = IconButton( - 'media-playback-start-symbolic', - label='Play All', - relief=True, + "media-playback-start-symbolic", label="Play All", relief=True, ) - play_button.connect('clicked', self.on_play_all_clicked) + play_button.connect("clicked", self.on_play_all_clicked) self.play_shuffle_buttons.pack_start(play_button, False, False, 0) shuffle_button = IconButton( - 'media-playlist-shuffle-symbolic', - label='Shuffle All', - relief=True, + "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True, ) - shuffle_button.connect('clicked', self.on_shuffle_all_button) + shuffle_button.connect("clicked", self.on_shuffle_all_button) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) playlist_details_box.add(self.play_shuffle_buttons) @@ -371,17 +357,16 @@ class PlaylistDetailPanel(Gtk.Overlay): return max(row_score(key, row) for row in rows) def playlist_song_list_search_fn( - model: Gtk.ListStore, - col: int, - key: str, - treeiter: Gtk.TreeIter, - data: Any = None, + model: Gtk.ListStore, + col: int, + key: str, + treeiter: Gtk.TreeIter, + data: Any = None, ) -> bool: # TODO (#28): this is very inefficient, it's slow when the result # is close to the bottom of the list. Would be good to research # what the default one does (maybe it uses an index?). - max_score = max_score_for_key( - key, tuple(tuple(row[1:4]) for row in model)) + max_score = max_score_for_key(key, tuple(tuple(row[1:4]) for row in model)) row_max_score = row_score(key, tuple(model[treeiter][1:4])) if row_max_score == max_score: return False # indicates match @@ -394,33 +379,31 @@ class PlaylistDetailPanel(Gtk.Overlay): enable_search=True, ) self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn) - self.playlist_songs.get_selection().set_mode( - Gtk.SelectionMode.MULTIPLE) + self.playlist_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) # Song status column. renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(30, 35) - column = Gtk.TreeViewColumn('', renderer, icon_name=0) + column = Gtk.TreeViewColumn("", renderer, icon_name=0) column.set_resizable(True) self.playlist_songs.append_column(column) + self.playlist_songs.append_column(SongListColumn("TITLE", 1, bold=True)) + self.playlist_songs.append_column(SongListColumn("ALBUM", 2)) + self.playlist_songs.append_column(SongListColumn("ARTIST", 3)) self.playlist_songs.append_column( - SongListColumn('TITLE', 1, bold=True)) - self.playlist_songs.append_column(SongListColumn('ALBUM', 2)) - self.playlist_songs.append_column(SongListColumn('ARTIST', 3)) - self.playlist_songs.append_column( - SongListColumn('DURATION', 4, align=1, width=40)) + SongListColumn("DURATION", 4, align=1, width=40) + ) - self.playlist_songs.connect('row-activated', self.on_song_activated) - self.playlist_songs.connect( - 'button-press-event', self.on_song_button_press) + self.playlist_songs.connect("row-activated", self.on_song_activated) + self.playlist_songs.connect("button-press-event", self.on_song_button_press) # Set up drag-and-drop on the song list for editing the order of the # playlist. self.playlist_song_store.connect( - 'row-inserted', self.on_playlist_model_row_move) - self.playlist_song_store.connect( - 'row-deleted', self.on_playlist_model_row_move) + "row-inserted", self.on_playlist_model_row_move + ) + self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move) playlist_box.add(self.playlist_songs) @@ -431,11 +414,8 @@ class PlaylistDetailPanel(Gtk.Overlay): playlist_view_spinner.start() self.playlist_view_loading_box = Gtk.Alignment( - name='playlist-view-overlay', - xalign=0.5, - yalign=0.5, - xscale=0.1, - yscale=0.1) + name="playlist-view-overlay", xalign=0.5, yalign=0.5, xscale=0.1, yscale=0.1 + ) self.playlist_view_loading_box.add(playlist_view_spinner) self.add_overlay(self.playlist_view_loading_box) @@ -444,10 +424,10 @@ class PlaylistDetailPanel(Gtk.Overlay): def update(self, app_config: AppConfiguration, force: bool = False): if app_config.state.selected_playlist_id is None: self.playlist_artwork.set_from_file(None) - self.playlist_indicator.set_markup('') - self.playlist_name.set_markup('') + self.playlist_indicator.set_markup("") + self.playlist_name.set_markup("") self.playlist_comment.hide() - self.playlist_stats.set_markup('') + self.playlist_stats.set_markup("") self.playlist_action_buttons.hide() self.play_shuffle_buttons.hide() self.playlist_view_loading_box.hide() @@ -484,8 +464,8 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_id = playlist.id # Update the info display. - self.playlist_indicator.set_markup('PLAYLIST') - self.playlist_name.set_markup(f'{playlist.name}') + self.playlist_indicator.set_markup("PLAYLIST") + self.playlist_name.set_markup(f"{playlist.name}") if playlist.comment: self.playlist_comment.set_text(playlist.comment) self.playlist_comment.show() @@ -495,8 +475,7 @@ class PlaylistDetailPanel(Gtk.Overlay): # Update the artwork. self.update_playlist_artwork( - playlist.cover_art, - order_token=order_token, + playlist.cover_art, order_token=order_token, ) # Update the song list model. This requires some fancy diffing to @@ -505,14 +484,14 @@ class PlaylistDetailPanel(Gtk.Overlay): new_store = [ [ - util.get_cached_status_icon( - CacheManager.get_cached_status(song)), + util.get_cached_status_icon(CacheManager.get_cached_status(song)), song.title, song.album, song.artist, util.format_song_duration(song.duration), song.id, - ] for song in playlist.songs + ] + for song in playlist.songs ] util.diff_song_store(self.playlist_song_store, new_store) @@ -551,7 +530,8 @@ class PlaylistDetailPanel(Gtk.Overlay): ) def on_playlist_edit_button_click(self, _: Any): - playlist = CacheManager.get_playlist(self.playlist_id).result() + assert self.playlist_id + playlist = AdapterManager.get_playlist_details(self.playlist_id).result() dialog = EditPlaylistDialog(self.get_toplevel(), playlist) playlist_deleted = False @@ -561,9 +541,9 @@ class PlaylistDetailPanel(Gtk.Overlay): if result == Gtk.ResponseType.OK: CacheManager.update_playlist( self.playlist_id, - name=dialog.data['name'].get_text(), - comment=dialog.data['comment'].get_text(), - public=dialog.data['public'].get_active(), + name=dialog.data["name"].get_text(), + comment=dialog.data["comment"].get_text(), + public=dialog.data["public"].get_active(), ) elif result == Gtk.ResponseType.NO: # Delete the playlist. @@ -571,7 +551,7 @@ class PlaylistDetailPanel(Gtk.Overlay): transient_for=self.get_toplevel(), message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.NONE, - text='Confirm deletion', + text="Confirm deletion", ) confirm_dialog.add_buttons( Gtk.STOCK_DELETE, @@ -580,8 +560,9 @@ class PlaylistDetailPanel(Gtk.Overlay): Gtk.ResponseType.CANCEL, ) confirm_dialog.format_secondary_markup( - 'Are you sure you want to delete the ' - f'"{playlist.name}" playlist?') + "Are you sure you want to delete the " + f'"{playlist.name}" playlist?' + ) result = confirm_dialog.run() confirm_dialog.destroy() if result == Gtk.ResponseType.YES: @@ -597,10 +578,11 @@ class PlaylistDetailPanel(Gtk.Overlay): CacheManager.delete_cached_cover_art(self.playlist_id) CacheManager.invalidate_playlists_cache() self.emit( - 'refresh-window', + "refresh-window", { - 'selected_playlist_id': - None if playlist_deleted else self.playlist_id + "selected_playlist_id": None + if playlist_deleted + else self.playlist_id }, True, ) @@ -611,9 +593,9 @@ class PlaylistDetailPanel(Gtk.Overlay): def download_state_change(*args): GLib.idle_add( lambda: self.update_playlist_view( - self.playlist_id, - order_token=self.update_playlist_view_order_token, - )) + self.playlist_id, order_token=self.update_playlist_view_order_token, + ) + ) song_ids = [s[-1] for s in self.playlist_song_store] CacheManager.batch_download_songs( @@ -624,43 +606,30 @@ class PlaylistDetailPanel(Gtk.Overlay): def on_play_all_clicked(self, _: Any): self.emit( - 'song-clicked', + "song-clicked", 0, [m[-1] for m in self.playlist_song_store], - { - 'force_shuffle_state': False, - 'active_playlist_id': self.playlist_id, - }, + {"force_shuffle_state": False, "active_playlist_id": self.playlist_id,}, ) def on_shuffle_all_button(self, _: Any): self.emit( - 'song-clicked', - randint(0, - len(self.playlist_song_store) - 1), + "song-clicked", + randint(0, len(self.playlist_song_store) - 1), [m[-1] for m in self.playlist_song_store], - { - 'force_shuffle_state': True, - 'active_playlist_id': self.playlist_id, - }, + {"force_shuffle_state": True, "active_playlist_id": self.playlist_id,}, ) def on_song_activated(self, _: Any, idx: Gtk.TreePath, col: Any): # The song ID is in the last column of the model. self.emit( - 'song-clicked', + "song-clicked", idx.get_indices()[0], [m[-1] for m in self.playlist_song_store], - { - 'active_playlist_id': self.playlist_id, - }, + {"active_playlist_id": self.playlist_id,}, ) - def on_song_button_press( - self, - tree: Gtk.TreeView, - event: Gdk.EventButton, - ) -> bool: + def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton,) -> bool: if event.button == 3: # Right click clicked_path = tree.get_path_at_pos(event.x, event.y) if not clicked_path: @@ -674,7 +643,8 @@ class PlaylistDetailPanel(Gtk.Overlay): lambda: self.update_playlist_view( self.playlist_id, order_token=self.update_playlist_view_order_token, - )) + ) + ) # Use the new selection instead of the old one for calculating what # to do the right click on. @@ -685,10 +655,8 @@ class PlaylistDetailPanel(Gtk.Overlay): song_ids = [self.playlist_song_store[p][-1] for p in paths] # Used to adjust for the header row. - bin_coords = tree.convert_tree_to_bin_window_coords( - event.x, event.y) - widget_coords = tree.convert_tree_to_widget_coords( - event.x, event.y) + bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y) + widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y) def on_remove_songs_click(_: Any): CacheManager.update_playlist( @@ -702,8 +670,8 @@ class PlaylistDetailPanel(Gtk.Overlay): ) remove_text = ( - 'Remove ' + util.pluralize('song', len(song_ids)) - + ' from playlist') + "Remove " + util.pluralize("song", len(song_ids)) + " from playlist" + ) util.show_song_popover( song_ids, event.x, @@ -741,25 +709,12 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_artwork.set_loading(True) self.playlist_view_loading_box.show_all() - def make_label( - self, - text: str = None, - name: str = None, - **params, - ) -> Gtk.Label: - return Gtk.Label( - label=text, - name=name, - halign=Gtk.Align.START, - **params, - ) + def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label: + return Gtk.Label(label=text, name=name, halign=Gtk.Align.START, **params,) - @util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k)) + @util.async_callback(lambda *a, **k: AdapterManager.get_playlist_details(*a, **k),) def _update_playlist_order( - self, - playlist: PlaylistDetails, - app_config: AppConfiguration, - **kwargs, + self, playlist: PlaylistDetails, app_config: AppConfiguration, **kwargs, ): self.playlist_view_loading_box.show_all() update_playlist_future = CacheManager.update_playlist( @@ -774,20 +729,22 @@ class PlaylistDetailPanel(Gtk.Overlay): playlist.id, force=True, order_token=self.update_playlist_view_order_token, - ))) + ) + ) + ) def _format_stats(self, playlist: PlaylistDetails) -> str: - created_date = playlist.created.strftime('%B %d, %Y') + created_date = playlist.created.strftime("%B %d, %Y") lines = [ util.dot_join( - f'Created by {playlist.owner} on {created_date}', + f"Created by {playlist.owner} on {created_date}", f"{'Not v' if not playlist.public else 'V'}isible to others", ), util.dot_join( - '{} {}'.format( - playlist.song_count, - util.pluralize("song", playlist.song_count)), + "{} {}".format( + playlist.song_count, util.pluralize("song", playlist.song_count) + ), util.format_sequence_duration(playlist.duration), ), ] - return '\n'.join(lines) + return "\n".join(lines) diff --git a/sublime/ui/settings.py b/sublime/ui/settings.py index 2303389..c531422 100644 --- a/sublime/ui/settings.py +++ b/sublime/ui/settings.py @@ -1,53 +1,51 @@ import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk from .common.edit_form_dialog import EditFormDialog class SettingsDialog(EditFormDialog): - title: str = 'Settings' + title: str = "Settings" initial_size = (450, 250) text_fields = [ ( - 'Port Number (for streaming to Chromecasts on the LAN) *', - 'port_number', + "Port Number (for streaming to Chromecasts on the LAN) *", + "port_number", False, ), ] boolean_fields = [ - ('Always stream songs', 'always_stream'), - ('When streaming, also download song', 'download_on_stream'), + ("Always stream songs", "always_stream"), + ("When streaming, also download song", "download_on_stream"), + ("Show a notification when a song begins to play", "song_play_notification"), ( - 'Show a notification when a song begins to play', - 'song_play_notification', - ), - ( - 'Serve locally cached files over the LAN to Chromecast devices. *', - 'serve_over_lan', + "Serve locally cached files over the LAN to Chromecast devices. *", + "serve_over_lan", ), ] numeric_fields = [ ( - 'How many songs in the play queue do you want to prefetch?', - 'prefetch_amount', + "How many songs in the play queue do you want to prefetch?", + "prefetch_amount", (0, 10, 1), 0, ), ( - 'How many song downloads do you want to allow concurrently?', - 'concurrent_download_limit', + "How many song downloads do you want to allow concurrently?", + "concurrent_download_limit", (1, 10, 1), 5, ), ] option_fields = [ - ('Replay Gain', 'replay_gain', ('Disabled', 'Track', 'Album')), + ("Replay Gain", "replay_gain", ("Disabled", "Track", "Album")), ] def __init__(self, *args, **kwargs): self.extra_label = Gtk.Label( - label='* Will be appplied after restarting Sublime Music', + label="* Will be appplied after restarting Sublime Music", justify=Gtk.Justification.LEFT, use_markup=True, ) diff --git a/sublime/ui/state.py b/sublime/ui/state.py index 244cd65..40f0fb8 100644 --- a/sublime/ui/state.py +++ b/sublime/ui/state.py @@ -12,50 +12,48 @@ class RepeatType(Enum): @property def icon(self) -> str: - icon_name = [ - 'repeat-symbolic', - 'repeat-symbolic', - 'repeat-song-symbolic', - ][self.value] - return f'media-playlist-{icon_name}' + icon_name = ["repeat-symbolic", "repeat-symbolic", "repeat-song-symbolic"][ + self.value + ] + return f"media-playlist-{icon_name}" def as_mpris_loop_status(self) -> str: - return ['None', 'Playlist', 'Track'][self.value] + return ["None", "Playlist", "Track"][self.value] @staticmethod - def from_mpris_loop_status(loop_status: str) -> 'RepeatType': + def from_mpris_loop_status(loop_status: str) -> "RepeatType": return { - 'None': RepeatType.NO_REPEAT, - 'Track': RepeatType.REPEAT_SONG, - 'Playlist': RepeatType.REPEAT_QUEUE, + "None": RepeatType.NO_REPEAT, + "Track": RepeatType.REPEAT_SONG, + "Playlist": RepeatType.REPEAT_QUEUE, }[loop_status] @dataclass class UIState: """Represents the UI state of the application.""" + version: int = 1 playing: bool = False current_song_index: int = -1 play_queue: List[str] = field(default_factory=list) old_play_queue: List[str] = field(default_factory=list) - _volume: Dict[str, float] = field( - default_factory=lambda: {'this device': 100.0}) + _volume: Dict[str, float] = field(default_factory=lambda: {"this device": 100.0}) is_muted: bool = False repeat_type: RepeatType = RepeatType.NO_REPEAT shuffle_on: bool = False song_progress: float = 0 - current_device: str = 'this device' - current_tab: str = 'albums' + current_device: str = "this device" + current_tab: str = "albums" selected_album_id: Optional[str] = None selected_artist_id: Optional[str] = None selected_browse_element_id: Optional[str] = None selected_playlist_id: Optional[str] = None # State for Album sort. - current_album_sort: str = 'random' - current_album_genre: str = 'Rock' - current_album_alphabetical_sort: str = 'name' + current_album_sort: str = "random" + current_album_genre: str = "Rock" + current_album_alphabetical_sort: str = "name" current_album_from_year: int = 2010 current_album_to_year: int = 2020 @@ -67,8 +65,12 @@ class UIState: @property def current_song(self) -> Optional[Child]: from sublime.cache_manager import CacheManager - if (not self.play_queue or self.current_song_index < 0 - or not CacheManager.ready()): + + if ( + not self.play_queue + or self.current_song_index < 0 + or not CacheManager.ready() + ): return None current_song_id = self.play_queue[self.current_song_index] diff --git a/sublime/ui/util.py b/sublime/ui/util.py index 38f8b8d..bf0df2d 100644 --- a/sublime/ui/util.py +++ b/sublime/ui/util.py @@ -16,12 +16,14 @@ from typing import ( import gi from deepdiff import DeepDiff -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GLib, Gtk +from sublime.adapters import AdapterManager +from sublime.adapters.api_objects import Playlist from sublime.cache_manager import CacheManager, SongCacheStatus from sublime.config import AppConfiguration -from sublime.server.api_objects import Playlist def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str: @@ -37,16 +39,12 @@ def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str: if isinstance(duration_secs, timedelta): duration_secs = round(duration_secs.total_seconds()) if not duration_secs: - return '-:--' + return "-:--" - return f'{duration_secs // 60}:{duration_secs % 60:02}' + return f"{duration_secs // 60}:{duration_secs % 60:02}" -def pluralize( - string: str, - number: int, - pluralized_form: str = None, -) -> str: +def pluralize(string: str, number: int, pluralized_form: str = None,) -> str: """ Pluralize the given string given the count as a number. @@ -58,7 +56,7 @@ def pluralize( 'foos' """ if number != 1: - return pluralized_form or f'{string}s' + return pluralized_form or f"{string}s" return string @@ -82,48 +80,46 @@ def format_sequence_duration(duration_secs: Union[int, timedelta]) -> str: format_components = [] if duration_hrs > 0: - hrs = '{} {}'.format(duration_hrs, pluralize('hour', duration_hrs)) + hrs = "{} {}".format(duration_hrs, pluralize("hour", duration_hrs)) format_components.append(hrs) if duration_mins > 0: - mins = '{} {}'.format( - duration_mins, pluralize('minute', duration_mins)) + mins = "{} {}".format(duration_mins, pluralize("minute", duration_mins)) format_components.append(mins) # Show seconds if there are no hours. if duration_hrs == 0: - secs = '{} {}'.format( - duration_secs, pluralize('second', duration_secs)) + secs = "{} {}".format(duration_secs, pluralize("second", duration_secs)) format_components.append(secs) - return ', '.join(format_components) + return ", ".join(format_components) def esc(string: Optional[str]) -> str: if string is None: - return '' - return string.replace('&', '&').replace(" target='_blank'", '') + return "" + return string.replace("&", "&").replace(" target='_blank'", "") def dot_join(*items: Any) -> str: """ Joins the given strings with a dot character. Filters out None values. """ - return ' • '.join(map(str, filter(lambda x: x is not None, items))) + return " • ".join(map(str, filter(lambda x: x is not None, items))) def get_cached_status_icon(cache_status: SongCacheStatus) -> str: cache_icon = { - SongCacheStatus.NOT_CACHED: '', - SongCacheStatus.CACHED: 'folder-download-symbolic', - SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic', - SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic', + SongCacheStatus.NOT_CACHED: "", + SongCacheStatus.CACHED: "folder-download-symbolic", + SongCacheStatus.PERMANENTLY_CACHED: "view-pin-symbolic", + SongCacheStatus.DOWNLOADING: "emblem-synchronizing-symbolic", } return cache_icon[cache_status] def _parse_diff_location(location: str) -> Tuple: - match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location) + match = re.match(r"root\[(\d*)\](?:\[(\d*)\]|\.(.*))?", location) return tuple(g for g in cast(Match, match).groups() if g is not None) @@ -136,18 +132,18 @@ def diff_song_store(store_to_edit: Any, new_store: Iterable[Any]): # Diff the lists to determine what needs to be changed. diff = DeepDiff(old_store, new_store) - changed = diff.get('values_changed', {}) - added = diff.get('iterable_item_added', {}) - removed = diff.get('iterable_item_removed', {}) + changed = diff.get("values_changed", {}) + added = diff.get("iterable_item_added", {}) + removed = diff.get("iterable_item_removed", {}) for edit_location, diff in changed.items(): idx, field = _parse_diff_location(edit_location) - store_to_edit[int(idx)][int(field)] = diff['new_value'] + store_to_edit[int(idx)][int(field)] = diff["new_value"] - for add_location, value in added.items(): + for _, value in added.items(): store_to_edit.append(value) - for remove_location, value in reversed(list(removed.items())): + for remove_location, _ in reversed(list(removed.items())): remove_at = int(_parse_diff_location(remove_location)[0]) del store_to_edit[remove_at] @@ -176,7 +172,7 @@ def show_song_popover( position: Gtk.PositionType = Gtk.PositionType.BOTTOM, on_download_state_change: Callable[[], None] = lambda: None, show_remove_from_playlist_button: bool = False, - extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = [], + extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = None, ): def on_download_songs_click(_: Any): CacheManager.batch_download_songs( @@ -187,8 +183,7 @@ def show_song_popover( def on_remove_downloads_click(_: Any): CacheManager.batch_delete_cached_songs( - song_ids, - on_song_delete=on_download_state_change, + song_ids, on_song_delete=on_download_state_change, ) def on_add_to_playlist_click(_: Any, playlist: Playlist): @@ -217,41 +212,43 @@ def show_song_popover( if download_sensitive or status == SongCacheStatus.NOT_CACHED: download_sensitive = True - if (remove_download_sensitive - or status in (SongCacheStatus.CACHED, - SongCacheStatus.PERMANENTLY_CACHED)): + if remove_download_sensitive or status in ( + SongCacheStatus.CACHED, + SongCacheStatus.PERMANENTLY_CACHED, + ): remove_download_sensitive = True go_to_album_button = Gtk.ModelButton( - text='Go to album', action_name='app.go-to-album') + text="Go to album", action_name="app.go-to-album" + ) if len(albums) == 1 and list(albums)[0] is not None: - album_value = GLib.Variant('s', list(albums)[0]) + album_value = GLib.Variant("s", list(albums)[0]) go_to_album_button.set_action_target_value(album_value) go_to_artist_button = Gtk.ModelButton( - text='Go to artist', action_name='app.go-to-artist') + text="Go to artist", action_name="app.go-to-artist" + ) if len(artists) == 1 and list(artists)[0] is not None: - artist_value = GLib.Variant('s', list(artists)[0]) + artist_value = GLib.Variant("s", list(artists)[0]) go_to_artist_button.set_action_target_value(artist_value) browse_to_song = Gtk.ModelButton( - text=f"Browse to {pluralize('song', song_count)}", - action_name='app.browse-to', + text=f"Browse to {pluralize('song', song_count)}", action_name="app.browse-to", ) if len(parents) == 1 and list(parents)[0] is not None: - parent_value = GLib.Variant('s', list(parents)[0]) + parent_value = GLib.Variant("s", list(parents)[0]) browse_to_song.set_action_target_value(parent_value) menu_items = [ Gtk.ModelButton( - text='Play next', - action_name='app.play-next', - action_target=GLib.Variant('as', song_ids), + text="Play next", + action_name="app.play-next", + action_target=GLib.Variant("as", song_ids), ), Gtk.ModelButton( - text='Add to queue', - action_name='app.add-to-queue', - action_target=GLib.Variant('as', song_ids), + text="Add to queue", + action_name="app.add-to-queue", + action_target=GLib.Variant("as", song_ids), ), Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), go_to_album_button, @@ -275,19 +272,19 @@ def show_song_popover( Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), Gtk.ModelButton( text=f"Add {pluralize('song', song_count)} to playlist", - menu_name='add-to-playlist', + menu_name="add-to-playlist", ), - *extra_menu_items, + *(extra_menu_items or []), ] for item in menu_items: if type(item) == tuple: el, fn = item - el.connect('clicked', fn) - el.get_style_context().add_class('menu-button') + el.connect("clicked", fn) + el.get_style_context().add_class("menu-button") vbox.pack_start(item[0], False, True, 0) else: - item.get_style_context().add_class('menu-button') + item.get_style_context().add_class("menu-button") vbox.pack_start(item, False, True, 0) popover.add(vbox) @@ -296,22 +293,18 @@ def show_song_popover( playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Back button - playlists_vbox.add( - Gtk.ModelButton( - inverted=True, - centered=True, - menu_name='main', - )) + playlists_vbox.add(Gtk.ModelButton(inverted=True, centered=True, menu_name="main",)) # The playlist buttons - for playlist in CacheManager.get_playlists().result(): + # TODO lazy load + for playlist in AdapterManager.get_playlists().result(): button = Gtk.ModelButton(text=playlist.name) - button.get_style_context().add_class('menu-button') - button.connect('clicked', on_add_to_playlist_click, playlist) + button.get_style_context().add_class("menu-button") + button.connect("clicked", on_add_to_playlist_click, playlist) playlists_vbox.pack_start(button, False, True, 0) popover.add(playlists_vbox) - popover.child_set_property(playlists_vbox, 'submenu', 'add-to-playlist') + popover.child_set_property(playlists_vbox, "submenu", "add-to-playlist") # Positioning of the popover. rect = Gdk.Rectangle() @@ -340,6 +333,7 @@ def async_callback( :param future_fn: a function which generates a :class:`concurrent.futures.Future` or :class:`CacheManager.Result`. """ + def decorator(callback_fn: Callable) -> Callable: @functools.wraps(callback_fn) def wrapper( @@ -351,10 +345,9 @@ def async_callback( **kwargs, ): if before_download: - on_before_download = ( - lambda: GLib.idle_add(before_download, self)) + on_before_download = lambda: GLib.idle_add(before_download, self) else: - on_before_download = (lambda: None) + on_before_download = lambda: None def future_callback(f: Union[Future, CacheManager.Result]): try: @@ -371,13 +364,11 @@ def async_callback( app_config=app_config, force=force, order_token=order_token, - )) + ) + ) future: Union[Future, CacheManager.Result] = future_fn( - *args, - before_download=on_before_download, - force=force, - **kwargs, + *args, before_download=on_before_download, force=force, **kwargs, ) future.add_done_callback(future_callback) diff --git a/tests/adapter_tests/filesystem_adapter_tests.py b/tests/adapter_tests/filesystem_adapter_tests.py index 5bf1f3e..1c0acdc 100644 --- a/tests/adapter_tests/filesystem_adapter_tests.py +++ b/tests/adapter_tests/filesystem_adapter_tests.py @@ -9,7 +9,7 @@ from sublime.adapters import CacheMissError from sublime.adapters.filesystem import FilesystemAdapter from sublime.adapters.subsonic import api_objects as SubsonicAPI -MOCK_DATA_FILES = Path(__file__).parent.joinpath('mock_data') +MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data") @pytest.fixture @@ -27,15 +27,13 @@ def cache_adapter(tmp_path: Path): def mock_data_files( - request_name: str, - mode: str = 'r', + request_name: str, mode: str = "r", ) -> Generator[Tuple[Path, Any], None, None]: """ - Yields all of the files in the mock_data directory that start with - ``request_name``. + Yields all of the files in the mock_data directory that start with ``request_name``. """ for file in MOCK_DATA_FILES.iterdir(): - if file.name.split('-')[0] in request_name: + if file.name.split("-")[0] in request_name: with open(file, mode) as f: yield file, f.read() @@ -45,8 +43,7 @@ def test_caching_get_playlists(cache_adapter: FilesystemAdapter): cache_adapter.get_playlists() # Ingest an empty list (for example, no playlists added yet to server). - cache_adapter.ingest_new_data( - FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), []) + cache_adapter.ingest_new_data(FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), []) # After the first cache miss of get_playlists, even if an empty list is # returned, the next one should not be a cache miss. @@ -57,16 +54,19 @@ def test_caching_get_playlists(cache_adapter: FilesystemAdapter): FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), [ - SubsonicAPI.Playlist('1', 'test1', comment='comment'), - SubsonicAPI.Playlist('2', 'test2'), + SubsonicAPI.Playlist("1", "test1", comment="comment"), + SubsonicAPI.Playlist("2", "test2"), ], ) playlists = cache_adapter.get_playlists() assert len(playlists) == 2 - assert (playlists[0].id, playlists[0].name, - playlists[0].comment) == ('1', 'test1', 'comment') - assert (playlists[1].id, playlists[1].name) == ('2', 'test2') + assert (playlists[0].id, playlists[0].name, playlists[0].comment) == ( + "1", + "test1", + "comment", + ) + assert (playlists[1].id, playlists[1].name) == ("2", "test2") def test_no_caching_get_playlists(adapter: FilesystemAdapter): @@ -82,38 +82,38 @@ def test_no_caching_get_playlists(adapter: FilesystemAdapter): def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter): with pytest.raises(CacheMissError): - cache_adapter.get_playlist_details('1') + cache_adapter.get_playlist_details("1") # Simulate the playlist being retrieved from Subsonic. songs = [ SubsonicAPI.Song( - '2', - 'Song 2', - parent='foo', - album='foo', - artist='foo', + "2", + "Song 2", + parent="foo", + album="foo", + artist="foo", duration=timedelta(seconds=20.8), - path='/foo/song2.mp3', + path="/foo/song2.mp3", ), SubsonicAPI.Song( - '1', - 'Song 1', - parent='foo', - album='foo', - artist='foo', + "1", + "Song 1", + parent="foo", + album="foo", + artist="foo", duration=timedelta(seconds=10.2), - path='/foo/song1.mp3', + path="/foo/song1.mp3", ), ] cache_adapter.ingest_new_data( FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, - ('1', ), - SubsonicAPI.PlaylistWithSongs('1', 'test1', songs=songs), + ("1",), + SubsonicAPI.PlaylistWithSongs("1", "test1", songs=songs), ) - playlist = cache_adapter.get_playlist_details('1') - assert playlist.id == '1' - assert playlist.name == 'test1' + playlist = cache_adapter.get_playlist_details("1") + assert playlist.id == "1" + assert playlist.name == "test1" assert playlist.song_count == 2 assert playlist.duration == timedelta(seconds=31) for actual, song in zip(playlist.songs, songs): @@ -123,42 +123,42 @@ def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter): # "Force refresh" the playlist songs = [ SubsonicAPI.Song( - '3', - 'Song 3', - parent='foo', - album='foo', - artist='foo', + "3", + "Song 3", + parent="foo", + album="foo", + artist="foo", duration=timedelta(seconds=10.2), - path='/foo/song3.mp3', + path="/foo/song3.mp3", ), SubsonicAPI.Song( - '1', - 'Song 1', - parent='foo', - album='foo', - artist='foo', + "1", + "Song 1", + parent="foo", + album="foo", + artist="foo", duration=timedelta(seconds=21.8), - path='/foo/song1.mp3', + path="/foo/song1.mp3", ), SubsonicAPI.Song( - '1', - 'Song 1', - parent='foo', - album='foo', - artist='foo', + "1", + "Song 1", + parent="foo", + album="foo", + artist="foo", duration=timedelta(seconds=21.8), - path='/foo/song1.mp3', + path="/foo/song1.mp3", ), ] cache_adapter.ingest_new_data( FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, - ('1', ), - SubsonicAPI.PlaylistWithSongs('1', 'foo', songs=songs), + ("1",), + SubsonicAPI.PlaylistWithSongs("1", "foo", songs=songs), ) - playlist = cache_adapter.get_playlist_details('1') - assert playlist.id == '1' - assert playlist.name == 'foo' + playlist = cache_adapter.get_playlist_details("1") + assert playlist.id == "1" + assert playlist.name == "foo" assert playlist.song_count == 3 assert playlist.duration == timedelta(seconds=53.8) for actual, song in zip(playlist.songs, songs): @@ -166,12 +166,12 @@ def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter): assert getattr(actual, k, None) == v with pytest.raises(CacheMissError): - cache_adapter.get_playlist_details('2') + cache_adapter.get_playlist_details("2") def test_no_caching_get_playlist_details(adapter: FilesystemAdapter): with pytest.raises(Exception): - adapter.get_playlist_details('1') + adapter.get_playlist_details("1") # TODO: Create a playlist (that should be allowed only if this is acting as # a ground truth adapter) @@ -186,48 +186,45 @@ def test_caching_get_playlist_then_details(cache_adapter: FilesystemAdapter): cache_adapter.ingest_new_data( FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), - [ - SubsonicAPI.Playlist('1', 'test1'), - SubsonicAPI.Playlist('2', 'test2'), - ], + [SubsonicAPI.Playlist("1", "test1"), SubsonicAPI.Playlist("2", "test2")], ) # Trying to get playlist details should generate a cache miss, but should # include the data that we know about. try: - cache_adapter.get_playlist_details('1') - assert False, 'DID NOT raise CacheMissError' + cache_adapter.get_playlist_details("1") + assert 0, "DID NOT raise CacheMissError" except CacheMissError as e: assert e.partial_data - assert e.partial_data.id == '1' - assert e.partial_data.name == 'test1' + assert e.partial_data.id == "1" + assert e.partial_data.name == "test1" # Simulate getting playlist details for id=1, then id=2 songs = [ SubsonicAPI.Song( - '3', - 'Song 3', - parent='foo', - album='foo', - artist='foo', + "3", + "Song 3", + parent="foo", + album="foo", + artist="foo", duration=timedelta(seconds=10.2), - path='/foo/song3.mp3', + path="/foo/song3.mp3", ), ] cache_adapter.ingest_new_data( FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, - ('1', ), - SubsonicAPI.PlaylistWithSongs('1', 'test1', songs=songs), + ("1",), + SubsonicAPI.PlaylistWithSongs("1", "test1", songs=songs), ) cache_adapter.ingest_new_data( FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, - ('2', ), - SubsonicAPI.PlaylistWithSongs('2', 'test2', songs=songs), + ("2",), + SubsonicAPI.PlaylistWithSongs("2", "test2", songs=songs), ) # Going back and getting playlist details for the first one should not # cache miss. - playlist = cache_adapter.get_playlist_details('1') - assert playlist.id == '1' - assert playlist.name == 'test1' + playlist = cache_adapter.get_playlist_details("1") + assert playlist.id == "1" + assert playlist.name == "test1" diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py index 2de53d4..f304f6e 100644 --- a/tests/adapter_tests/subsonic_adapter_tests.py +++ b/tests/adapter_tests/subsonic_adapter_tests.py @@ -12,16 +12,16 @@ from sublime.adapters.subsonic import ( SubsonicAdapter, ) -MOCK_DATA_FILES = Path(__file__).parent.joinpath('mock_data') +MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data") @pytest.fixture def adapter(tmp_path: Path): adapter = SubsonicAdapter( { - 'server_address': 'http://subsonic.example.com', - 'username': 'test', - 'password': 'testpass', + "server_address": "http://subsonic.example.com", + "username": "test", + "password": "testpass", }, tmp_path, ) @@ -30,47 +30,39 @@ def adapter(tmp_path: Path): def mock_data_files( - request_name: str, - mode: str = 'r', + request_name: str, mode: str = "r" ) -> Generator[Tuple[Path, Any], None, None]: """ - Yields all of the files in the mock_data directory that start with - ``request_name``. + Yields all of the files in the mock_data directory that start with ``request_name``. """ for file in MOCK_DATA_FILES.iterdir(): - if file.name.split('-')[0] in request_name: + if file.name.split("-")[0] in request_name: with open(file, mode) as f: yield file, f.read() def mock_json(**obj: Any) -> str: return json.dumps( - { - 'subsonic-response': { - 'status': 'ok', - 'version': '1.15.0', - **obj, - }, - }) + {"subsonic-response": {"status": "ok", "version": "1.15.0", **obj}} + ) def camel_to_snake(name: str) -> str: - name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) - return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() def test_request_making_methods(adapter: SubsonicAdapter): expected = { - 'u': 'test', - 'p': 'testpass', - 'c': 'Sublime Music', - 'f': 'json', - 'v': '1.15.0', + "u": "test", + "p": "testpass", + "c": "Sublime Music", + "f": "json", + "v": "1.15.0", } - assert (sorted(expected.items()) == sorted(adapter._get_params().items())) + assert sorted(expected.items()) == sorted(adapter._get_params().items()) - assert adapter._make_url( - 'foo') == 'http://subsonic.example.com/rest/foo.view' + assert adapter._make_url("foo") == "http://subsonic.example.com/rest/foo.view" def test_can_service_requests(adapter: SubsonicAdapter): @@ -79,7 +71,7 @@ def test_can_service_requests(adapter: SubsonicAdapter): assert adapter.can_service_requests is False # Simulate some sort of ping error - for filename, data in mock_data_files('ping_failed'): + for filename, data in mock_data_files("ping_failed"): logging.info(filename) logging.debug(data) adapter._set_mock_data(data) @@ -93,32 +85,32 @@ def test_can_service_requests(adapter: SubsonicAdapter): def test_get_playlists(adapter: SubsonicAdapter): expected = [ SubsonicAPI.Playlist( - id='2', - name='Test', + id="2", + name="Test", song_count=132, duration=timedelta(seconds=33072), created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=timezone.utc), changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=timezone.utc), - comment='Foo', - owner='foo', + comment="Foo", + owner="foo", public=True, - cover_art='pl-2', + cover_art="pl-2", ), SubsonicAPI.Playlist( - id='3', - name='Bar', + id="3", + name="Bar", song_count=23, duration=timedelta(seconds=847), created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=timezone.utc), changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=timezone.utc), - comment='', - owner='foo', + comment="", + owner="foo", public=False, - cover_art='pl-3', + cover_art="pl-3", ), ] - for filename, data in mock_data_files('get_playlists'): + for filename, data in mock_data_files("get_playlists"): logging.info(filename) logging.debug(data) adapter._set_mock_data(data) @@ -130,12 +122,12 @@ def test_get_playlists(adapter: SubsonicAdapter): def test_get_playlist_details(adapter: SubsonicAdapter): - for filename, data in mock_data_files('get_playlist_details'): + for filename, data in mock_data_files("get_playlist_details"): logging.info(filename) logging.debug(data) adapter._set_mock_data(data) - playlist_details = adapter.get_playlist_details('2') + playlist_details = adapter.get_playlist_details("2") # Make sure that the song count is correct even if it's not provided. # Old versions of Subsonic don't have these properties. @@ -144,15 +136,15 @@ def test_get_playlist_details(adapter: SubsonicAdapter): # Make sure that at least the first song got decoded properly. assert playlist_details.songs[0] == SubsonicAPI.Song( - id='202', - parent='318', - title='What a Beautiful Name', - album='What a Beautiful Name - Single', - artist='Hillsong Worship', + id="202", + parent="318", + title="What a Beautiful Name", + album="What a Beautiful Name - Single", + artist="Hillsong Worship", track=1, year=2016, - genre='Christian & Gospel', - cover_art='318', + genre="Christian & Gospel", + cover_art="318", size=8381640, content_type="audio/mp4", suffix="m4a", @@ -160,12 +152,13 @@ def test_get_playlist_details(adapter: SubsonicAdapter): transcoded_suffix="mp3", duration=timedelta(seconds=238), bit_rate=256, - path='/'.join( + path="/".join( ( - 'Hillsong Worship', - 'What a Beautiful Name - Single', - '01 What a Beautiful Name.m4a', - )), + "Hillsong Worship", + "What a Beautiful Name - Single", + "01 What a Beautiful Name.m4a", + ) + ), is_video=False, play_count=20, disc_number=1, diff --git a/tests/config_test.py b/tests/config_test.py index 73cea34..4f76e1c 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -4,38 +4,32 @@ from pathlib import Path import yaml -from sublime.config import ( - AppConfiguration, ReplayGainType, ServerConfiguration) +from sublime.config import AppConfiguration, ReplayGainType, ServerConfiguration def test_config_default_cache_location(): config = AppConfiguration() - assert config.cache_location == os.path.expanduser( - '~/.local/share/sublime-music') + assert config.cache_location == os.path.expanduser("~/.local/share/sublime-music") def test_server_property(): config = AppConfiguration() - server = ServerConfiguration( - name='foo', server_address='bar', username='baz') + server = ServerConfiguration(name="foo", server_address="bar", username="baz") config.servers.append(server) assert config.server is None config.current_server_index = 0 assert asdict(config.server) == asdict(server) - expected_state_file_location = Path('~/.local/share').expanduser() + expected_state_file_location = Path("~/.local/share").expanduser() expected_state_file_location = expected_state_file_location.joinpath( - 'sublime-music', - '6df23dc03f9b54cc38a0fc1483df6e21', - 'state.pickle', + "sublime-music", "6df23dc03f9b54cc38a0fc1483df6e21", "state.pickle", ) assert config.state_file_location == expected_state_file_location def test_yaml_load_unload(): config = AppConfiguration() - server = ServerConfiguration( - name='foo', server_address='bar', username='baz') + server = ServerConfiguration(name="foo", server_address="bar", username="baz") config.servers.append(server) config.current_server_index = 0 @@ -54,9 +48,7 @@ def test_yaml_load_unload(): def test_config_migrate(): config = AppConfiguration() server = ServerConfiguration( - name='Test', - server_address='https://test.host', - username='test', + name="Test", server_address="https://test.host", username="test" ) config.servers.append(server) config.migrate()