Run black on entire project

This commit is contained in:
Sumner Evans
2020-04-23 20:17:45 -06:00
parent e5b3e659ff
commit 0ed2c266d8
52 changed files with 2603 additions and 2917 deletions

23
.editorconfig Normal file
View File

@@ -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

View File

@@ -19,6 +19,7 @@ lint:
- ./cicd/install-project-deps.sh - ./cicd/install-project-deps.sh
script: script:
- pipenv run python setup.py check -mrs - pipenv run python setup.py check -mrs
- pipenv run black --check .
- pipenv run flake8 - pipenv run flake8
- pipenv run mypy sublime tests/**/*.py - pipenv run mypy sublime tests/**/*.py
- pipenv run cicd/custom_style_check.py - pipenv run cicd/custom_style_check.py

View File

@@ -5,5 +5,5 @@
"python.linting.flake8Enabled": true, "python.linting.flake8Enabled": true,
"python.linting.enabled": true, "python.linting.enabled": true,
"python.linting.mypyEnabled": true, "python.linting.mypyEnabled": true,
"python.formatting.provider": "yapf" "python.formatting.provider": "black"
} }

View File

@@ -78,12 +78,41 @@ Building the flatpak
Code Style Code Style
---------- ----------
* `PEP-8`_ is to be followed **strictly**. This project follows `PEP-8`_ **strictly**. The *only* exception is maximum line
* `mypy`_ is used for type checking. length, which is 88 for this project (in accordance with ``black``'s defaults).
* ``print`` statements are not to be used except for when you actually want to Additionally, lines that contain a single string literal are allowed to extend
print to the terminal (which should be rare). In all other cases, the more past that.
powerful and useful ``logging`` library should be used. 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/ .. _`PEP-8`: https://www.python.org/dev/peps/pep-0008/
.. _mypy: http://mypy-lang.org/ .. _mypy: http://mypy-lang.org/

11
Pipfile
View File

@@ -4,10 +4,12 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages] [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 = "*" docutils = "*"
flake8 = "*" flake8 = {git = "https://gitlab.com/pycqa/flake8",ref = "master"}
flake8-annotations = "*" flake8-annotations = "*"
flake8-bugbear = "*"
flake8-comprehensions = "*" flake8-comprehensions = "*"
flake8-import-order = "*" flake8-import-order = "*"
flake8-pep3101 = "*" flake8-pep3101 = "*"
@@ -15,6 +17,7 @@ flake8-print = "*"
graphviz = "*" graphviz = "*"
lxml = "*" lxml = "*"
mypy = "*" mypy = "*"
pycodestyle = "==2.6.0a1"
pytest = "*" pytest = "*"
pytest-cov = "*" pytest-cov = "*"
rope = "*" rope = "*"
@@ -22,10 +25,12 @@ rst2html5 = "*"
sphinx = "*" sphinx = "*"
sphinx-rtd-theme = "*" sphinx-rtd-theme = "*"
termcolor = "*" termcolor = "*"
yapf = "*"
[packages] [packages]
sublime-music = {editable = true,extras = ["keyring"],path = "."} sublime-music = {editable = true,extras = ["keyring"],path = "."}
[requires] [requires]
python_version = "3.8" python_version = "3.8"
[pipenv]
allow_prereleases = true

219
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "1b3ed7bc26fc014d648a0fddf4cde814f5ea583a464bf457548326a67825601c" "sha256": "dfed6ede3c95b6007e782cdce8117157f9d5e02aed39af78acf6055bb6dd9d75"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -192,26 +192,24 @@
}, },
"protobuf": { "protobuf": {
"hashes": [ "hashes": [
"sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab", "sha256:03b31ec00ad94d4947fd87f49b288e60f443370fd1927fae80411d2dd864fbb5",
"sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f", "sha256:0b845c1fb8f36be203cd2ca9e405a22ee2cec2ed87d180b067d7c063f5701633",
"sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a", "sha256:0d1fec40323c8e10812897c71453c33401f6ccc6ade98c5a3fef1f019de797e6",
"sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0", "sha256:375ab5683efc946d1340dcf53dd422ccb55fbe88c0e16408182ca9a73248d91e",
"sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4", "sha256:3c5a1a0acd42a3fa39ce0b1436cd7faaa1e77ecaac58cd87101f56b2fe99f628",
"sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2", "sha256:3f01f6a479aff857615f2decaba773470816727fa6be6291866bd966d6ae3c61",
"sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee", "sha256:4cae6edd604ddbbaadd90da13df06fdf399d3fa9f19950e78340ab69f59f103c",
"sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07", "sha256:4edae95bff0e4a010059462b4a0116366863573c105ba689fc19ed9dae16888d",
"sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151", "sha256:67412c3eb0299a2c908d86dea1ceab9e65558684abd2f53e9f85ae28f03ba7b3",
"sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a", "sha256:8765978e2e553a7a9a7d4aa64b957f111a0358d85d799e378dc458b653ea2de5",
"sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f", "sha256:8bba760eb61044120cb91552f55c4b2fa3a80c8639fae8583b53b3e3a7e8da56",
"sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7", "sha256:996542402404aa8577defcdebbf9a0780bd96c7af2f562eefd4542716ca369a1",
"sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956", "sha256:a4cb8388c3f75d36ac51667e678f4c3096f672229d3e68d1db18675d4f59e5a2",
"sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306", "sha256:b8404d27772f130299185e20e4379a2b3450c7d1197396131cc2ec4626db75cb",
"sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961", "sha256:b9c9692d2842ff7846b0c2574be8e921247b7c377f4c03cd6370aef077fb652c",
"sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481", "sha256:caed753a89e5ffc2fbbf624926eacc3924c884181374bd3ddf54ca0a2903eb11"
"sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a",
"sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80"
], ],
"version": "==3.11.3" "version": "==3.12.0rc1"
}, },
"pycairo": { "pycairo": {
"hashes": [ "hashes": [
@@ -354,6 +352,13 @@
], ],
"version": "==0.7.12" "version": "==0.7.12"
}, },
"appdirs": {
"hashes": [
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
],
"version": "==1.4.3"
},
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
@@ -368,6 +373,14 @@
], ],
"version": "==2.8.0" "version": "==2.8.0"
}, },
"black": {
"hashes": [
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
],
"index": "pypi",
"version": "==19.10b0"
},
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
@@ -382,6 +395,13 @@
], ],
"version": "==3.0.4" "version": "==3.0.4"
}, },
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
"version": "==7.1.2"
},
"coverage": { "coverage": {
"hashes": [ "hashes": [
"sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
@@ -433,20 +453,9 @@
"index": "pypi", "index": "pypi",
"version": "==0.16" "version": "==0.16"
}, },
"entrypoints": {
"hashes": [
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
],
"version": "==0.3"
},
"flake8": { "flake8": {
"hashes": [ "git": "https://gitlab.com/pycqa/flake8",
"sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", "ref": "0c3b8045a7b51aec7abf19dea94d5292cebeeea0"
"sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
],
"index": "pypi",
"version": "==3.7.9"
}, },
"flake8-annotations": { "flake8-annotations": {
"hashes": [ "hashes": [
@@ -456,6 +465,14 @@
"index": "pypi", "index": "pypi",
"version": "==2.1.0" "version": "==2.1.0"
}, },
"flake8-bugbear": {
"hashes": [
"sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63",
"sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"
],
"index": "pypi",
"version": "==20.1.4"
},
"flake8-comprehensions": { "flake8-comprehensions": {
"hashes": [ "hashes": [
"sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2", "sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2",
@@ -518,10 +535,10 @@
}, },
"jinja2": { "jinja2": {
"hashes": [ "hashes": [
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" "sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
], ],
"version": "==2.11.2" "version": "==3.0.0a1"
}, },
"lxml": { "lxml": {
"hashes": [ "hashes": [
@@ -558,41 +575,30 @@
}, },
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", "sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3",
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", "sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f"
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
], ],
"version": "==1.1.1" "version": "==2.0.0a1"
}, },
"mccabe": { "mccabe": {
"hashes": [ "hashes": [
@@ -642,6 +648,13 @@
], ],
"version": "==20.3" "version": "==20.3"
}, },
"pathspec": {
"hashes": [
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
"sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
],
"version": "==0.8.0"
},
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
@@ -658,17 +671,18 @@
}, },
"pycodestyle": { "pycodestyle": {
"hashes": [ "hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "sha256:933bfe8d45355fbb35f9017d81fc51df8cb7ce58b82aca2568b870bf7bea1611",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" "sha256:c1362bf675a7c0171fa5f795917c570c2e405a97e5dc473b51f3656075d73acc"
], ],
"version": "==2.5.0" "index": "pypi",
"version": "==2.6.0a1"
}, },
"pyflakes": { "pyflakes": {
"hashes": [ "hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
], ],
"version": "==2.1.1" "version": "==2.2.0"
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
@@ -679,10 +693,10 @@
}, },
"pyparsing": { "pyparsing": {
"hashes": [ "hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:67199f0c41a9c702154efb0e7a8cc08accf830eb003b4d9fa42c4059002e2492",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" "sha256:700d17888d441604b0bd51535908dcb297561b040819cccde647a92439db5a2a"
], ],
"version": "==2.4.7" "version": "==3.0.0a1"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
@@ -707,6 +721,32 @@
], ],
"version": "==2020.1" "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": { "requests": {
"hashes": [ "hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
@@ -752,11 +792,11 @@
}, },
"sphinx-rtd-theme": { "sphinx-rtd-theme": {
"hashes": [ "hashes": [
"sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", "sha256:1ba9bbc8898ed8531ac8d140b4ff286d57010fb878303b2efae3303726ec821b",
"sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" "sha256:a18194ae459f6a59b0d56e4a8b4c576c0125fb9a12f2211e652b4a8133092e14"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.4.3" "version": "==0.5.0rc1"
}, },
"sphinxcontrib-applehelp": { "sphinxcontrib-applehelp": {
"hashes": [ "hashes": [
@@ -807,6 +847,13 @@
"index": "pypi", "index": "pypi",
"version": "==1.1.0" "version": "==1.1.0"
}, },
"toml": {
"hashes": [
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
],
"version": "==0.10.0"
},
"typed-ast": { "typed-ast": {
"hashes": [ "hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
@@ -854,14 +901,6 @@
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
], ],
"version": "==0.1.9" "version": "==0.1.9"
},
"yapf": {
"hashes": [
"sha256:3000abee4c28daebad55da6c85f3cd07b8062ce48e2e9943c8da1b9667d48427",
"sha256:3abf61ba67cf603069710d30acbc88cfe565d907e16ad81429ae90ce9651e0c9"
],
"index": "pypi",
"version": "==0.30.0"
} }
} }
} }

View File

@@ -6,18 +6,18 @@ from pathlib import Path
from termcolor import cprint from termcolor import cprint
todo_re = re.compile(r'#\s*TODO:?\s*') todo_re = re.compile(r"#\s*TODO:?\s*")
accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)') accounted_for_todo = re.compile(r"#\s*TODO:?\s*\((#\d+)\)")
def check_file(path: Path) -> bool: def check_file(path: Path) -> bool:
print(f'Checking {path.absolute()}...') # noqa: T001 print(f"Checking {path.absolute()}...") # noqa: T001
file = path.open() file = path.open()
valid = True valid = True
for i, line in enumerate(file, start=1): for i, line in enumerate(file, start=1):
if todo_re.search(line) and not accounted_for_todo.search(line): 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 valid = False
file.close() file.close()
@@ -25,7 +25,7 @@ def check_file(path: Path) -> bool:
valid = True valid = True
for path in Path('sublime').glob('**/*.py'): for path in Path("sublime").glob("**/*.py"):
valid &= check_file(path) valid &= check_file(path)
print() # noqa: T001 print() # noqa: T001

View File

@@ -18,14 +18,15 @@
import datetime import datetime
project = 'Sublime Music' project = "Sublime Music"
copyright = f'{datetime.datetime.today().year}, Sumner Evans' copyright = f"{datetime.datetime.today().year}, Sumner Evans"
author = 'Sumner Evans' author = "Sumner Evans"
gitlab = 'https://gitlab.com/sumner/sublime-music/' gitlab = "https://gitlab.com/sumner/sublime-music/"
# Get the version from the package. # Get the version from the package.
import sublime import sublime
release = f'v{sublime.__version__}'
release = f"v{sublime.__version__}"
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
@@ -33,37 +34,37 @@ release = f'v{sublime.__version__}'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx.ext.autosectionlabel', "sphinx.ext.autosectionlabel",
'sphinx.ext.intersphinx', "sphinx.ext.intersphinx",
'sphinx.ext.mathjax', "sphinx.ext.mathjax",
'sphinx.ext.viewcode', "sphinx.ext.viewcode",
] ]
autodoc_default_options = { autodoc_default_options = {
'members': True, "members": True,
'undoc-members': True, "undoc-members": True,
'show-inheritance': True, "show-inheritance": True,
'special-members': '__init__', "special-members": "__init__",
} }
autosectionlabel_prefix_document = True autosectionlabel_prefix_document = True
intersphinx_mapping = { 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. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ["_templates"]
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = "index"
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path. # 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. # The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx' pygments_style = "sphinx"
rst_epilog = f""" rst_epilog = f"""
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
@@ -81,9 +82,9 @@ rst_epilog = f"""
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # 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, # 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, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ["_static"]

View File

@@ -1,6 +1,8 @@
[flake8] [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 exclude = .git,__pycache__,build,dist,flatpak
max-line-length = 80
suppress-none-returning = True suppress-none-returning = True
suppress-dummy-args = True suppress-dummy-args = True
application-import-names = sublime application-import-names = sublime

View File

@@ -5,83 +5,72 @@ from setuptools import find_packages, setup
here = os.path.abspath(os.path.dirname(__file__)) 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() long_description = f.read()
# Find the version # Find the version
with codecs.open(os.path.join(here, 'sublime/__init__.py'), with codecs.open(os.path.join(here, "sublime/__init__.py"), encoding="utf-8") as f:
encoding='utf-8') as f:
for line in f: for line in f:
if line.startswith('__version__'): if line.startswith("__version__"):
version = eval(line.split()[-1]) version = eval(line.split()[-1])
break break
setup( setup(
name='sublime-music', name="sublime-music",
version=version, version=version,
url='https://gitlab.com/sumner/sublime-music', url="https://gitlab.com/sumner/sublime-music",
description='A native GTK *sonic client.', description="A native GTK *sonic client.",
long_description=long_description, long_description=long_description,
author='Sumner Evans', author="Sumner Evans",
author_email='inquiries@sumnerevans.com', author_email="inquiries@sumnerevans.com",
license='GPL3', license="GPL3",
classifiers=[ classifiers=[
# 3 - Alpha # 3 - Alpha
# 4 - Beta # 4 - Beta
# 5 - Production/Stable # 5 - Production/Stable
'Development Status :: 3 - Alpha', "Development Status :: 3 - Alpha",
# Indicate who your project is intended for # Indicate who your project is intended for
'Intended Audience :: End Users/Desktop', "Intended Audience :: End Users/Desktop",
'Operating System :: POSIX', "Operating System :: POSIX",
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
# Specify the Python versions you support here. In particular, ensure # Specify the Python versions you support here. In particular, ensure
# that you indicate whether you support Python 2, Python 3 or both. # that you indicate whether you support Python 2, Python 3 or both.
'Programming Language :: Python :: 3.6', "Programming Language :: Python :: 3.6",
'Programming Language :: Python :: 3.7', "Programming Language :: Python :: 3.7",
], ],
keywords='airsonic subsonic libresonic gonic music', keywords="airsonic subsonic libresonic gonic music",
packages=find_packages(exclude=['tests']), packages=find_packages(exclude=["tests"]),
package_data={ package_data={
'sublime': [ "sublime": [
'ui/app_styles.css', "ui/app_styles.css",
'ui/images/play-queue-play.png', "ui/images/play-queue-play.png",
'ui/images/default-album-art.png', "ui/images/default-album-art.png",
'dbus/mpris_specs/org.mpris.MediaPlayer2.xml', "dbus/mpris_specs/org.mpris.MediaPlayer2.xml",
'dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml', "dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml",
'dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml', "dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml",
'dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml', "dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml",
] ]
}, },
install_requires=[ install_requires=[
'bottle', "bottle",
'dataclasses-json @ git+https://github.com/sumnerevans/dataclasses-json@cc2eaeb#egg=dataclasses-json', # noqa: E501 "dataclasses-json @ git+https://github.com/sumnerevans/dataclasses-json@cc2eaeb#egg=dataclasses-json", # noqa: E501
'deepdiff', "deepdiff",
'Deprecated', "Deprecated",
'fuzzywuzzy', "fuzzywuzzy",
'osxmmkeys ; sys_platform=="darwin"', 'osxmmkeys ; sys_platform=="darwin"',
'peewee', "peewee",
'pychromecast', "pychromecast",
'PyGObject', "PyGObject",
'python-dateutil', "python-dateutil",
'python-Levenshtein', "python-Levenshtein",
'python-mpv', "python-mpv",
'pyyaml', "pyyaml",
'requests', "requests",
], ],
extras_require={ extras_require={"keyring": ["keyring"]},
"keyring": ["keyring"],
},
# To provide executable scripts, use entry points in preference to the # To provide executable scripts, use entry points in preference to the
# "scripts" keyword. Entry points provide cross-platform support and # "scripts" keyword. Entry points provide cross-platform support and
# allow pip to create the appropriate form of executable for the target # allow pip to create the appropriate form of executable for the target
# platform. # platform.
entry_points={ entry_points={"console_scripts": ["sublime-music=sublime.__main__:main"]},
'console_scripts': [
'sublime-music=sublime.__main__:main',
],
},
) )

View File

@@ -5,7 +5,8 @@ import os
from pathlib import Path from pathlib import Path
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa: F401 from gi.repository import Gtk # noqa: F401
import sublime import sublime
@@ -13,55 +14,50 @@ from sublime.app import SublimeMusicApp
def main(): def main():
parser = argparse.ArgumentParser(description='Sublime Music') parser = argparse.ArgumentParser(description="Sublime Music")
parser.add_argument( parser.add_argument(
'-v', "-v", "--version", help="show version and exit", action="store_true"
'--version', )
help='show version and exit', parser.add_argument("-l", "--logfile", help="the filename to send logs to")
action='store_true', parser.add_argument(
"-m", "--loglevel", help="the minium level of logging to do", default="WARNING",
) )
parser.add_argument( parser.add_argument(
'-l', "-c",
'--logfile', "--config",
help='the filename to send logs to', help="specify a configuration file. Defaults to "
) "~/.config/sublime-music/config.json",
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',
) )
args, unknown_args = parser.parse_known_args() args, unknown_args = parser.parse_known_args()
if args.version: if args.version:
print(f'Sublime Music v{sublime.__version__}') # noqa: T001 print(f"Sublime Music v{sublime.__version__}") # noqa: T001
return return
min_log_level = getattr(logging, args.loglevel.upper(), None) min_log_level = getattr(logging, args.loglevel.upper(), None)
if not isinstance(min_log_level, int): 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 min_log_level = logging.WARNING
logging.basicConfig( logging.basicConfig(
filename=args.logfile, filename=args.logfile,
level=min_log_level, 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
config_file = args.config config_file = args.config
if not config_file: if not config_file:
# Default to ~/.config/sublime-music. # Default to ~/.config/sublime-music.
config_file = Path( config_file = (
os.environ.get('XDG_CONFIG_HOME') or os.environ.get('APPDATA') Path(
or os.path.join('~/.config')).expanduser().joinpath( os.environ.get("XDG_CONFIG_HOME")
'sublime-music', 'config.yaml') or os.environ.get("APPDATA")
or os.path.join("~/.config")
)
.expanduser()
.joinpath("sublime-music", "config.yaml")
)
app = SublimeMusicApp(Path(config_file)) app = SublimeMusicApp(Path(config_file))
app.run(unknown_args) app.run(unknown_args)

View File

@@ -1,15 +1,10 @@
from .adapter_base import ( from .adapter_base import Adapter, CacheMissError, CachingAdapter, ConfigParamDescriptor
Adapter,
CacheMissError,
CachingAdapter,
ConfigParamDescriptor,
)
from .adapter_manager import AdapterManager from .adapter_manager import AdapterManager
__all__ = ( __all__ = (
'Adapter', "Adapter",
'AdapterManager', "AdapterManager",
'CacheMissError', "CacheMissError",
'CachingAdapter', "CachingAdapter",
'ConfigParamDescriptor', "ConfigParamDescriptor",
) )

View File

@@ -21,22 +21,21 @@ from .api_objects import (
class CacheMissError(Exception): class CacheMissError(Exception):
""" """
This exception should be thrown by caching adapters when the request data This exception should be thrown by caching adapters when the request data is not
is not available or is invalid. If some of the data is available, but not available or is invalid. If some of the data is available, but not all of it, the
all of it, the ``partial_data`` parameter should be set with the partial ``partial_data`` parameter should be set with the partial data. If the ground truth
data. If the ground truth adapter can't service the request, or errors for adapter can't service the request, or errors for some reason, the UI will try to
some reason, the UI will try to populate itself with the partial data populate itself with the partial data returned in this exception (with the necessary
returned in this exception (with the necessary error text to inform the error text to inform the user that retrieval from the ground truth adapter failed).
user that retrieval from the ground truth adapter failed).
""" """
def __init__(self, *args, partial_data: Any = None): def __init__(self, *args, partial_data: Any = None):
""" """
Create a :class:`CacheMissError` exception. Create a :class:`CacheMissError` exception.
:param args: arguments to pass to the :class:`BaseException` base :param args: arguments to pass to the :class:`BaseException` base class.
class. :param partial_data: the actual partial data for the UI to use in case of ground
:param partial_data: the actual partial data for the UI to use in case truth adapter failure.
of ground truth adapter failure.
""" """
self.partial_data = partial_data self.partial_data = partial_data
super().__init__(*args) super().__init__(*args)
@@ -46,31 +45,30 @@ class CacheMissError(Exception):
class ConfigParamDescriptor: class ConfigParamDescriptor:
""" """
Describes a parameter that can be used to configure an adapter. The Describes a parameter that can be used to configure an adapter. The
:class:`description`, :class:`required` and :class:`default:` should be :class:`description`, :class:`required` and :class:`default:` should be self-evident
self-evident as to what they do. as to what they do.
The :class:`type` must be one of the following: The :class:`type` must be one of the following:
* The literal type ``str``: corresponds to a freeform text entry field in * The literal type ``str``: corresponds to a freeform text entry field in the UI.
the UI.
* The literal type ``bool``: corresponds to a checkbox 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 type ``int``: corresponds to a numeric input in the UI.
* The literal string ``"password"``: corresponds to a password entry field * The literal string ``"password"``: corresponds to a password entry field in the
in the UI. UI.
* The literal string ``"option"``: corresponds to dropdown in the UI. * The literal string ``"option"``: corresponds to dropdown in the UI.
The :class:`numeric_bounds` parameter only has an effect if the The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
:class:`type` is `int`. It specifies the min and max values that the UI `int`. It specifies the min and max values that the UI control can have.
control can have.
The :class:`numeric_step` parameter only has an effect if the :class:`type` The :class:`numeric_step` parameter only has an effect if the :class:`type` is
is `int`. It specifies the step that will be taken using the "+" and "-" `int`. It specifies the step that will be taken using the "+" and "-" buttons on the
buttons on the UI control (if supported). UI control (if supported).
The :class:`options` parameter only has an effect if the :class:`type` is 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 ``"option"``. It specifies the list of options that will be available in the
the dropdown in the UI. dropdown in the UI.
""" """
type: Union[Type, str] type: Union[Type, str]
description: str description: str
required: bool = True required: bool = True
@@ -84,10 +82,11 @@ class Adapter(abc.ABC):
""" """
Defines the interface for a Sublime Music Adapter. Defines the interface for a Sublime Music Adapter.
All functions that actually retrieve data have a corresponding: All functions that actually retrieve data have a corresponding: ``can_``-prefixed
``can_``-prefixed property (which can be dynamic) which specifies whether property (which can be dynamic) which specifies whether or not the adapter supports
or not the adapter supports that operation at the moment. that operation at the moment.
""" """
# Configuration and Initialization Properties # Configuration and Initialization Properties
# These properties determine how the adapter can be configured and how to # These properties determine how the adapter can be configured and how to
# initialize the adapter given those configuration values. # 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. Specifies the settings which can be configured for the adapter.
:returns: An dictionary where the keys are the name of the :returns: An dictionary where the keys are the name of the configuration
configuration paramter and the values are the paramter and the values are the :class:`ConfigParamDescriptor` object
:class:`ConfigParamDescriptor` object corresponding to that corresponding to that configuration parameter. The order of the keys in the
configuration parameter. The order of the keys in the dictionary dictionary correspond to the order that the configuration parameters will be
correspond to the order that the configuration parameters will be
shown in the UI. shown in the UI.
""" """
@staticmethod @staticmethod
@abc.abstractmethod @abc.abstractmethod
def verify_configuration( def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
config: Dict[str, Any]) -> Dict[str, Optional[str]]:
""" """
Specifies a function for verifying whether or not the config is valid. Specifies a function for verifying whether or not the config is valid.
:param config: The adapter configuration. The keys of are the :param config: The adapter configuration. The keys of are the configuration
configuration parameter names as defined by the return value of the parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual :class:`get_config_parameters` function. The values are the actual value of
value of the configuration parameter. It is guaranteed that all the configuration parameter. It is guaranteed that all configuration
configuration parameters that are marked as required will have a parameters that are marked as required will have a value in ``config``.
value in ``config``.
:returns: A dictionary containing varification errors. The keys of the :returns: A dictionary containing varification errors. The keys of the returned
returned dictionary should be the same as the passed in via the dictionary should be the same as the passed in via the ``config`` parameter.
``config`` parameter. The values should be strings describing why The values should be strings describing why the corresponding value in the
the corresponding value in the ``config`` dictionary is invalid. ``config`` dictionary is invalid.
Not all keys need be returned (for example, if there's no error for Not all keys need be returned (for example, if there's no error for a given
a given configuration parameter), and returning `None` indicates no configuration parameter), and returning `None` indicates no error.
error.
""" """
@abc.abstractmethod @abc.abstractmethod
def __init__(self, config: dict, data_directory: Path): def __init__(self, config: dict, data_directory: Path):
""" """
This function should be overridden by inheritors of This function should be overridden by inheritors of :class:`Adapter` and should
:class:`Adapter` and should be used to do whatever setup is be used to do whatever setup is required for the adapter.
required for the adapter.
:param config: The adapter configuration. The keys of are the :param config: The adapter configuration. The keys of are the configuration
configuration parameter names as defined by the return value of the parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual :class:`get_config_parameters` function. The values are the actual value of
value of the configuration parameter. the configuration parameter.
:param data_directory: the directory where the adapter can store data. :param data_directory: the directory where the adapter can store data. This
This directory is guaranteed to exist. directory is guaranteed to exist.
""" """
def shutdown(self): def shutdown(self):
""" """
This function is called when the app is being closed or the server is This function is called when the app is being closed or the server is changing.
changing. This should be used to clean up anything that is necessary This should be used to clean up anything that is necessary such as writing a
such as writing a cache to disk, disconnecting from a server, etc. cache to disk, disconnecting from a server, etc.
""" """
# Usage Properties # Usage Properties
@@ -159,11 +153,11 @@ class Adapter(abc.ABC):
@property @property
def can_be_cached(self) -> bool: def can_be_cached(self) -> bool:
""" """
Specifies whether or not this adapter can be used as the ground-truth Specifies whether or not this adapter can be used as the ground-truth adapter
adapter behind a caching adapter. behind a caching adapter.
The default is ``True``, since most adapters will want to take The default is ``True``, since most adapters will want to take advantage of the
advantage of the built-in filesystem cache. built-in filesystem cache.
""" """
return True return True
@@ -175,13 +169,12 @@ class Adapter(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
def can_service_requests(self) -> bool: def can_service_requests(self) -> bool:
""" """
Specifies whether or not the adapter can currently service requests. If Specifies whether or not the adapter can currently service requests. If this is
this is ``False``, none of the other data retrieval functions are ``False``, none of the other data retrieval functions are expected to work.
expected to work.
For example, if your adapter requires access to an external service, For example, if your adapter requires access to an external service, use this
use this function to determine if it is currently possible to connect function to determine if it is currently possible to connect to that external
to that external service. service.
""" """
@property @property
@@ -194,8 +187,7 @@ class Adapter(abc.ABC):
@property @property
def can_get_playlist_details(self) -> bool: def can_get_playlist_details(self) -> bool:
""" """
Whether :class:`get_playlist_details` can be called on the adapter Whether :class:`get_playlist_details` can be called on the adapter right now.
right now.
""" """
return False return False
@@ -208,86 +200,77 @@ class Adapter(abc.ABC):
# ========================================================================= # =========================================================================
def get_playlists(self) -> Sequence[Playlist]: def get_playlists(self) -> Sequence[Playlist]:
""" """
Gets a list of all of the :class:`sublime.adapter.api_objects.Playlist` Gets a list of all of the :class:`sublime.adapter.api_objects.Playlist` objects
objects known to the adapter. known to the adapter.
""" """
raise self._check_can_error('get_playlists') raise self._check_can_error("get_playlists")
def get_playlist_details( def get_playlist_details(self, playlist_id: str,) -> PlaylistDetails:
self,
playlist_id: str,
) -> PlaylistDetails:
""" """
Gets the details about the given ``playlist_id``. If the playlist_id Gets the details about the given ``playlist_id``. If the playlist_id does not
does not exist, then this function should throw an exception. exist, then this function should throw an exception.
:param playlist_id: The ID of the playlist to retrieve. :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 @staticmethod
def _check_can_error(method_name: str) -> NotImplementedError: def _check_can_error(method_name: str) -> NotImplementedError:
return NotImplementedError( return NotImplementedError(
f'Adapter.{method_name} called. ' f"Adapter.{method_name} called. "
'Did you forget to check that can_{method_name} is True?') "Did you forget to check that can_{method_name} is True?"
)
class CachingAdapter(Adapter): class CachingAdapter(Adapter):
""" """
Defines an adapter that can be used as a cache for another 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 A caching adapter sits "in front" of a non-caching adapter and the UI will attempt
will attempt to retrieve the data from the caching adapter before to retrieve the data from the caching adapter before retrieving it from the
retrieving it from the non-caching adapter. (The exception is when the non-caching adapter. (The exception is when the UI requests that the data come
UI requests that the data come directly from the ground truth adapter, directly from the ground truth adapter, in which case the cache will be bypassed.)
in which case the cache will be bypassed.)
Caching adapters *must* be able to service requests instantly, or Caching adapters *must* be able to service requests instantly, or nearly instantly
nearly instantly (in most cases, this meanst the data must come (in most cases, this meanst the data must come directly from the local filesystem).
directly from the local filesystem).
""" """
@abc.abstractmethod @abc.abstractmethod
def __init__( def __init__(self, config: dict, data_directory: Path, is_cache: bool = False):
self,
config: dict,
data_directory: Path,
is_cache: bool = False,
):
""" """
This function should be overridden by inheritors of This function should be overridden by inheritors of :class:`CachingAdapter` and
:class:`CachingAdapter` and should be used to do whatever setup is should be used to do whatever setup is required for the adapter.
required for the adapter.
:param config: The adapter configuration. The keys of are the :param config: The adapter configuration. The keys of are the configuration
configuration parameter names as defined by the return value of the parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual :class:`get_config_parameters` function. The values are the actual value of
value of the configuration parameter. the configuration parameter.
:param data_directory: the directory where the adapter can store data. :param data_directory: the directory where the adapter can store data. This
This directory is guaranteed to exist. directory is guaranteed to exist.
:param is_cache: whether or not the adapter is being used as a cache. :param is_cache: whether or not the adapter is being used as a cache.
""" """
# Data Ingestion Methods # Data Ingestion Methods
# ========================================================================= # =========================================================================
class FunctionNames(Enum): class FunctionNames(Enum):
GET_PLAYLISTS = 'get_playlists' GET_PLAYLISTS = "get_playlists"
GET_PLAYLIST_DETAILS = 'get_playlist_details' GET_PLAYLIST_DETAILS = "get_playlist_details"
@abc.abstractmethod @abc.abstractmethod
def ingest_new_data( def ingest_new_data(
self, self,
function: 'CachingAdapter.FunctionNames', function: "CachingAdapter.FunctionNames",
params: Tuple[Any, ...], params: Tuple[Any, ...],
data: Any, data: Any,
): ):
""" """
This function will be called after the fallback, ground-truth adapter This function will be called after the fallback, ground-truth adapter returns
returns new data. This normally will happen if this adapter has a cache new data. This normally will happen if this adapter has a cache miss or if the
miss or if the UI forces retrieval from the ground-truth adapter. UI forces retrieval from the ground-truth adapter.
:param function_name: the name of the function that was called on the :param function_name: the name of the function that was called on the ground
ground truth adapter. truth adapter.
:param params: the parameters that were passed to the function on the :param params: the parameters that were passed to the function on the ground
ground truth adapter. truth adapter.
:param data: the data that was returned by the ground truth adapter. :param data: the data that was returned by the ground truth adapter.
""" """

View File

@@ -21,15 +21,16 @@ from .api_objects import Playlist, PlaylistDetails
from .filesystem import FilesystemAdapter from .filesystem import FilesystemAdapter
from .subsonic import SubsonicAdapter from .subsonic import SubsonicAdapter
T = TypeVar('T') T = TypeVar("T")
class Result(Generic[T]): class Result(Generic[T]):
""" """
A result from a :class:`AdapterManager` function. This is effectively a A result from a :class:`AdapterManager` function. This is effectively a wrapper
wrapper around a :class:`concurrent.futures.Future`, but it can also around a :class:`concurrent.futures.Future`, but it can also resolve immediately if
resolve immediately if the data already exists. the data already exists.
""" """
_data: Optional[T] = None _data: Optional[T] = None
_future: Optional[Future] = None _future: Optional[Future] = None
on_cancel: Optional[Callable[[], None]] = None on_cancel: Optional[Callable[[], None]] = None
@@ -51,8 +52,7 @@ class Result(Generic[T]):
if self._future is not None: if self._future is not None:
return self._future.result() return self._future.result()
raise Exception( raise Exception("AdapterManager.Result had neither _data nor _future member!")
'AdapterManager.Result had neither _data nor _future member!')
def add_done_callback(self, fn: Callable, *args): def add_done_callback(self, fn: Callable, *args):
if self._future is not None: if self._future is not None:
@@ -91,28 +91,28 @@ class AdapterManager:
@staticmethod @staticmethod
def register_adapter(adapter_class: Type): def register_adapter(adapter_class: Type):
if not issubclass(adapter_class, Adapter): if not issubclass(adapter_class, Adapter):
raise TypeError( raise TypeError("Attempting to register a class that is not an adapter.")
'Attempting to register a class that is not an adapter.')
AdapterManager.available_adapters.add(adapter_class) AdapterManager.available_adapters.add(adapter_class)
def __init__(self): def __init__(self):
""" """
This should not ever be called. You should only ever use the static This should not ever be called. You should only ever use the static methods on
methods on this class. this class.
""" """
raise Exception( raise Exception(
"Cannot instantiate AdapterManager. Only use the static methods " "Do not instantiate the AdapterManager. "
"on the class.") "Only use the static methods on the class."
)
@staticmethod @staticmethod
def shutdown(): def shutdown():
logging.info('AdapterManager shutdown start') logging.info("AdapterManager shutdown start")
AdapterManager.is_shutting_down = True AdapterManager.is_shutting_down = True
AdapterManager.executor.shutdown() AdapterManager.executor.shutdown()
if AdapterManager._instance: if AdapterManager._instance:
AdapterManager._instance.shutdown() AdapterManager._instance.shutdown()
logging.info('CacheManager shutdown complete') logging.info("CacheManager shutdown complete")
@staticmethod @staticmethod
def reset(config: AppConfiguration): def reset(config: AppConfiguration):
@@ -124,8 +124,8 @@ class AdapterManager:
# to create, etc. # to create, etc.
assert config.server is not None assert config.server is not None
source_data_dir = Path(config.cache_location, config.server.strhash()) 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("g").mkdir(parents=True, exist_ok=True)
source_data_dir.joinpath('c').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_type = SubsonicAdapter
ground_truth_adapter = ground_truth_adapter_type( ground_truth_adapter = ground_truth_adapter_type(
@@ -133,7 +133,7 @@ class AdapterManager:
key: getattr(config.server, key) key: getattr(config.server, key)
for key in ground_truth_adapter_type.get_config_parameters() for key in ground_truth_adapter_type.get_config_parameters()
}, },
source_data_dir.joinpath('g'), source_data_dir.joinpath("g"),
) )
caching_adapter_type = FilesystemAdapter caching_adapter_type = FilesystemAdapter
@@ -144,23 +144,22 @@ class AdapterManager:
key: getattr(config.server, key) key: getattr(config.server, key)
for key in caching_adapter_type.get_config_parameters() for key in caching_adapter_type.get_config_parameters()
}, },
source_data_dir.joinpath('c'), source_data_dir.joinpath("c"),
is_cache=True, is_cache=True,
) )
AdapterManager._instance = AdapterManager._AdapterManagerInternal( AdapterManager._instance = AdapterManager._AdapterManagerInternal(
ground_truth_adapter, ground_truth_adapter, caching_adapter=caching_adapter,
caching_adapter=caching_adapter,
) )
@staticmethod @staticmethod
def can_get_playlists() -> bool: def can_get_playlists() -> bool:
# It only matters that the ground truth one can service the request. # It only matters that the ground truth one can service the request.
return ( return (
AdapterManager._instance is not None and AdapterManager._instance is not None
AdapterManager._instance.ground_truth_adapter.can_service_requests and AdapterManager._instance.ground_truth_adapter.can_service_requests
and and AdapterManager._instance.ground_truth_adapter.can_get_playlists
AdapterManager._instance.ground_truth_adapter.can_get_playlists) )
@staticmethod @staticmethod
def get_playlists( def get_playlists(
@@ -168,32 +167,31 @@ class AdapterManager:
force: bool = False, # TODO: rename to use_ground_truth_adapter? force: bool = False, # TODO: rename to use_ground_truth_adapter?
) -> Result[Sequence[Playlist]]: ) -> Result[Sequence[Playlist]]:
assert AdapterManager._instance assert AdapterManager._instance
if (not force and AdapterManager._instance.caching_adapter and if (
AdapterManager._instance.caching_adapter.can_service_requests not force
and and AdapterManager._instance.caching_adapter
AdapterManager._instance.caching_adapter.can_get_playlists): and AdapterManager._instance.caching_adapter.can_service_requests
and AdapterManager._instance.caching_adapter.can_get_playlists
):
try: try:
return Result( return Result(AdapterManager._instance.caching_adapter.get_playlists())
AdapterManager._instance.caching_adapter.get_playlists())
except CacheMissError: except CacheMissError:
logging.debug(f'Cache Miss on {"get_playlists"}.') logging.debug(f'Cache Miss on {"get_playlists"}.')
except Exception: except Exception:
logging.exception( logging.exception(f'Error on {"get_playlists"} retrieving from cache.')
f'Error on {"get_playlists"} retrieving from cache.')
if (AdapterManager._instance.ground_truth_adapter if (
and not AdapterManager._instance.ground_truth_adapter AdapterManager._instance.ground_truth_adapter
.can_service_requests and not AdapterManager._instance and not AdapterManager._instance.ground_truth_adapter.can_service_requests
.ground_truth_adapter.can_get_playlists): and not AdapterManager._instance.ground_truth_adapter.can_get_playlists
raise Exception( ):
f'No adapters can service {"get_playlists"} at the moment.') raise Exception(f'No adapters can service {"get_playlists"} at the moment.')
def future_fn() -> Sequence[Playlist]: def future_fn() -> Sequence[Playlist]:
assert AdapterManager._instance assert AdapterManager._instance
if before_download: if before_download:
before_download() before_download()
return ( return AdapterManager._instance.ground_truth_adapter.get_playlists()
AdapterManager._instance.ground_truth_adapter.get_playlists())
future: Result[Sequence[Playlist]] = Result(future_fn) future: Result[Sequence[Playlist]] = Result(future_fn)
@@ -203,9 +201,7 @@ class AdapterManager:
assert AdapterManager._instance assert AdapterManager._instance
assert AdapterManager._instance.caching_adapter assert AdapterManager._instance.caching_adapter
AdapterManager._instance.caching_adapter.ingest_new_data( AdapterManager._instance.caching_adapter.ingest_new_data(
CachingAdapter.FunctionNames.GET_PLAYLISTS, CachingAdapter.FunctionNames.GET_PLAYLISTS, (), f.result(),
(),
f.result(),
) )
future.add_done_callback(future_finished) future.add_done_callback(future_finished)
@@ -216,9 +212,10 @@ class AdapterManager:
def can_get_playlist_details() -> bool: def can_get_playlist_details() -> bool:
# It only matters that the ground truth one can service the request. # It only matters that the ground truth one can service the request.
return ( return (
AdapterManager._instance.ground_truth_adapter.can_service_requests AdapterManager._instance is not None
and AdapterManager._instance.ground_truth_adapter and AdapterManager._instance.ground_truth_adapter.can_service_requests
.can_get_playlist_details) and AdapterManager._instance.ground_truth_adapter.can_get_playlist_details
)
@staticmethod @staticmethod
def get_playlist_details( def get_playlist_details(
@@ -228,14 +225,18 @@ class AdapterManager:
) -> Result[PlaylistDetails]: ) -> Result[PlaylistDetails]:
assert AdapterManager._instance assert AdapterManager._instance
partial_playlist_data = None partial_playlist_data = None
if (not force and AdapterManager._instance.caching_adapter and if (
AdapterManager._instance.caching_adapter.can_service_requests not force
and AdapterManager._instance.caching_adapter and AdapterManager._instance.caching_adapter
.can_get_playlist_details): and AdapterManager._instance.caching_adapter.can_service_requests
and AdapterManager._instance.caching_adapter.can_get_playlist_details
):
try: try:
return Result( return Result(
AdapterManager._instance.caching_adapter AdapterManager._instance.caching_adapter.get_playlist_details(
.get_playlist_details(playlist_id)) playlist_id
)
)
except CacheMissError as e: except CacheMissError as e:
partial_playlist_data = e.partial_data partial_playlist_data = e.partial_data
logging.debug(f'Cache Miss on {"get_playlist_details"}.') logging.debug(f'Cache Miss on {"get_playlist_details"}.')
@@ -244,10 +245,13 @@ class AdapterManager:
f'Error on {"get_playlist_details"} retrieving from cache.' f'Error on {"get_playlist_details"} retrieving from cache.'
) )
if (AdapterManager._instance.ground_truth_adapter if (
and not AdapterManager._instance.ground_truth_adapter AdapterManager._instance.ground_truth_adapter
.can_service_requests and not AdapterManager._instance and not AdapterManager._instance.ground_truth_adapter.can_service_requests
.ground_truth_adapter.can_get_playlist_details): and not (
AdapterManager._instance.ground_truth_adapter.can_get_playlist_details
)
):
if partial_playlist_data: if partial_playlist_data:
# TODO do something here # TODO do something here
pass pass
@@ -259,9 +263,9 @@ class AdapterManager:
assert AdapterManager._instance assert AdapterManager._instance
if before_download: if before_download:
before_download() before_download()
return ( return AdapterManager._instance.ground_truth_adapter.get_playlist_details(
AdapterManager._instance.ground_truth_adapter playlist_id
.get_playlist_details(playlist_id)) )
future: Result[PlaylistDetails] = Result(future_fn) future: Result[PlaylistDetails] = Result(future_fn)
@@ -272,7 +276,7 @@ class AdapterManager:
assert AdapterManager._instance.caching_adapter assert AdapterManager._instance.caching_adapter
AdapterManager._instance.caching_adapter.ingest_new_data( AdapterManager._instance.caching_adapter.ingest_new_data(
CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS, CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
(playlist_id, ), (playlist_id,),
f.result(), f.result(),
) )

View File

@@ -8,10 +8,10 @@ from typing import Optional, Sequence
class MediaType(Enum): class MediaType(Enum):
MUSIC = 'music' MUSIC = "music"
PODCAST = 'podcast' PODCAST = "podcast"
AUDIOBOOK = 'audiobook' AUDIOBOOK = "audiobook"
VIDEO = 'video' VIDEO = "video"
class Song(abc.ABC): class Song(abc.ABC):

View File

@@ -1,3 +1,3 @@
from .adapter import FilesystemAdapter from .adapter import FilesystemAdapter
__all__ = ('FilesystemAdapter', ) __all__ = ("FilesystemAdapter",)

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Tuple 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 models
from .. import CacheMissError, CachingAdapter, ConfigParamDescriptor from .. import CacheMissError, CachingAdapter, ConfigParamDescriptor
@@ -16,6 +16,7 @@ class FilesystemAdapter(CachingAdapter):
""" """
Defines an adapter which retrieves its data from the local filesystem. Defines an adapter which retrieves its data from the local filesystem.
""" """
# Configuration and Initialization Properties # Configuration and Initialization Properties
# ========================================================================= # =========================================================================
@staticmethod @staticmethod
@@ -25,19 +26,15 @@ class FilesystemAdapter(CachingAdapter):
} }
@staticmethod @staticmethod
def verify_configuration( def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
config: Dict[str, Any]) -> Dict[str, Optional[str]]:
return {} return {}
def __init__( def __init__(
self, self, config: dict, data_directory: Path, is_cache: bool = False,
config: dict,
data_directory: Path,
is_cache: bool = False,
): ):
self.data_directory = data_directory self.data_directory = data_directory
self.is_cache = is_cache 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.init(database_filename)
models.database.connect() models.database.connect()
@@ -45,7 +42,7 @@ class FilesystemAdapter(CachingAdapter):
models.database.create_tables(models.ALL_TABLES) models.database.create_tables(models.ALL_TABLES)
def shutdown(self): def shutdown(self):
logging.info('Shutdown complete') logging.info("Shutdown complete")
# Usage Properties # Usage Properties
# ========================================================================= # =========================================================================
@@ -59,7 +56,7 @@ class FilesystemAdapter(CachingAdapter):
# Data Helper Methods # Data Helper Methods
# ========================================================================= # =========================================================================
def _params_hash(self, *params: Any) -> str: 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 # Data Retrieval Methods
# ========================================================================= # =========================================================================
@@ -74,20 +71,17 @@ class FilesystemAdapter(CachingAdapter):
# not, cache miss. # not, cache miss.
function_name = CachingAdapter.FunctionNames.GET_PLAYLISTS function_name = CachingAdapter.FunctionNames.GET_PLAYLISTS
if not models.CacheInfo.get_or_none( if not models.CacheInfo.get_or_none(
models.CacheInfo.query_name == function_name): models.CacheInfo.query_name == function_name
):
raise CacheMissError() raise CacheMissError()
return playlists return playlists
can_get_playlist_details: bool = True can_get_playlist_details: bool = True
def get_playlist_details( def get_playlist_details(self, playlist_id: str,) -> PlaylistDetails:
self, playlist = models.Playlist.get_or_none(models.Playlist.id == playlist_id)
playlist_id: str,
) -> PlaylistDetails:
playlist = models.Playlist.get_or_none(
models.Playlist.id == playlist_id)
if not playlist and not self.is_cache: 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 # If we haven't ingested data for this playlist before, raise a
# CacheMissError with the partial playlist data. # CacheMissError with the partial playlist data.
@@ -106,11 +100,11 @@ class FilesystemAdapter(CachingAdapter):
@models.database.atomic() @models.database.atomic()
def ingest_new_data( def ingest_new_data(
self, self,
function: 'CachingAdapter.FunctionNames', function: "CachingAdapter.FunctionNames",
params: Tuple[Any, ...], params: Tuple[Any, ...],
data: 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( models.CacheInfo.insert(
query_name=function, query_name=function,
@@ -119,24 +113,25 @@ class FilesystemAdapter(CachingAdapter):
).on_conflict_replace().execute() ).on_conflict_replace().execute()
if function == CachingAdapter.FunctionNames.GET_PLAYLISTS: if function == CachingAdapter.FunctionNames.GET_PLAYLISTS:
models.Playlist.insert_many(map( models.Playlist.insert_many(
asdict, data)).on_conflict_replace().execute() map(asdict, data)
).on_conflict_replace().execute()
elif function == CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS: elif function == CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS:
playlist_data = asdict(data) playlist_data = asdict(data)
playlist, playlist_created = models.Playlist.get_or_create( playlist, playlist_created = models.Playlist.get_or_create(
id=playlist_data['id'], id=playlist_data["id"], defaults=playlist_data,
defaults=playlist_data,
) )
# Handle the songs. # Handle the songs.
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())) # 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( 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: if not song_created:
for key in keys: for key in keys:
setattr(song, key, song_data[key]) setattr(song, key, song_data[key])
@@ -145,7 +140,7 @@ class FilesystemAdapter(CachingAdapter):
songs.append(song) songs.append(song)
playlist.songs = songs playlist.songs = songs
del playlist_data['songs'] del playlist_data["songs"]
# Update the values if the playlist already existed. # Update the values if the playlist already existed.
if not playlist_created: if not playlist_created:

View File

@@ -70,7 +70,7 @@ class CacheInfo(BaseModel):
last_ingestion_time = TzDateTimeField(null=False) last_ingestion_time = TzDateTimeField(null=False)
class Meta: class Meta:
primary_key = CompositeKey('query_name', 'params_hash') primary_key = CompositeKey("query_name", "params_hash")
class Playlist(BaseModel): class Playlist(BaseModel):
@@ -85,7 +85,7 @@ class Playlist(BaseModel):
public = BooleanField(null=True) public = BooleanField(null=True)
cover_art = TextField(null=True) cover_art = TextField(null=True)
songs = SortedManyToManyField(Song, backref='playlists') songs = SortedManyToManyField(Song, backref="playlists")
ALL_TABLES = ( ALL_TABLES = (

View File

@@ -53,7 +53,7 @@ class SortedManyToManyQuery(ManyToManyQuery):
accessor = self._accessor accessor = self._accessor
src_id = getattr(self._instance, self._src_attr) src_id = getattr(self._instance, self._src_attr)
if isinstance(value, SelectQuery): if isinstance(value, SelectQuery):
print('TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT') print("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT")
raise NotImplementedError() raise NotImplementedError()
# query = value.columns(Value(src_id), accessor.dest_fk.rel_field) # query = value.columns(Value(src_id), accessor.dest_fk.rel_field)
# accessor.through_model.insert_from( # accessor.through_model.insert_from(
@@ -68,13 +68,14 @@ class SortedManyToManyQuery(ManyToManyQuery):
{ {
accessor.src_fk.name: src_id, accessor.src_fk.name: src_id,
accessor.dest_fk.name: rel_id, accessor.dest_fk.name: rel_id,
'position': i, "position": i,
} for i, rel_id in enumerate(self._id_list(value)) }
for i, rel_id in enumerate(self._id_list(value))
] ]
accessor.through_model.insert_many(inserts).execute() accessor.through_model.insert_many(inserts).execute()
def remove(self, value: Any) -> Any: def remove(self, value: Any) -> Any:
print('RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR') print("RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR")
raise NotImplementedError() raise NotImplementedError()
# src_id = getattr(self._instance, self._src_attr) # src_id = getattr(self._instance, self._src_attr)
# if isinstance(value, SelectQuery): # if isinstance(value, SelectQuery):
@@ -102,23 +103,22 @@ class SortedManyToManyQuery(ManyToManyQuery):
class SortedManyToManyFieldAccessor(ManyToManyFieldAccessor): class SortedManyToManyFieldAccessor(ManyToManyFieldAccessor):
def __get__( def __get__(
self, self, instance: Model, instance_type: Any = None, force_query: bool = False,
instance: Model,
instance_type: Any = None,
force_query: bool = False,
): ):
if instance is not None: 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) backref = getattr(instance, self.src_fk.backref)
if isinstance(backref, list): if isinstance(backref, list):
return [getattr(obj, self.dest_fk.name) for obj in backref] return [getattr(obj, self.dest_fk.name) for obj in backref]
src_id = getattr(instance, self.src_fk.rel_field.name) src_id = getattr(instance, self.src_fk.rel_field.name)
return SortedManyToManyQuery(instance, self, self.rel_model) \ return (
.join(self.through_model) \ SortedManyToManyQuery(instance, self, self.rel_model)
.join(self.model) \ .join(self.through_model)
.where(self.src_fk == src_id) \ .join(self.model)
.where(self.src_fk == src_id)
.order_by(self.through_model.position) .order_by(self.through_model.position)
)
return self.field return self.field
@@ -137,16 +137,16 @@ class SortedManyToManyField(ManyToManyField):
class Meta: class Meta:
database = self.model._meta.database database = self.model._meta.database
schema = self.model._meta.schema schema = self.model._meta.schema
table_name = '{}_{}_through'.format(*tables) table_name = "{}_{}_through".format(*tables)
indexes = (((lhs._meta.name, rhs._meta.name, 'position'), True), ) 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 = { attrs = {
lhs._meta.name: ForeignKeyField(lhs, **params), lhs._meta.name: ForeignKeyField(lhs, **params),
rhs._meta.name: ForeignKeyField(rhs, **params), rhs._meta.name: ForeignKeyField(rhs, **params),
'position': IntegerField(), "position": IntegerField(),
'Meta': Meta "Meta": Meta,
} }
klass_name = '{}{}Through'.format(lhs.__name__, rhs.__name__) klass_name = "{}{}Through".format(lhs.__name__, rhs.__name__)
return type(klass_name, (Model, ), attrs) return type(klass_name, (Model,), attrs)

View File

@@ -1,3 +1,3 @@
from .adapter import SubsonicAdapter from .adapter import SubsonicAdapter
__all__ = ('SubsonicAdapter', ) __all__ = ("SubsonicAdapter",)

View File

@@ -16,34 +16,30 @@ class SubsonicAdapter(Adapter):
""" """
Defines an adapter which retrieves its data from a Subsonic server Defines an adapter which retrieves its data from a Subsonic server
""" """
# Configuration and Initialization Properties # Configuration and Initialization Properties
# ========================================================================= # =========================================================================
@staticmethod @staticmethod
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]: def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
return { return {
'server_address': "server_address": ConfigParamDescriptor(str, "Server address"),
ConfigParamDescriptor(str, 'Server address'), "username": ConfigParamDescriptor(str, "Username"),
'username': "password": ConfigParamDescriptor("password", "Password"),
ConfigParamDescriptor(str, 'Username'), "disable_cert_verify": ConfigParamDescriptor("password", "Password", False),
'password':
ConfigParamDescriptor('password', 'Password'),
'disable_cert_verify':
ConfigParamDescriptor('password', 'Password', False),
} }
@staticmethod @staticmethod
def verify_configuration( def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
config: Dict[str, Any]) -> Dict[str, Optional[str]]:
errors: Dict[str, Optional[str]] = {} errors: Dict[str, Optional[str]] = {}
# TODO: verify the URL # TODO: verify the URL
return errors return errors
def __init__(self, config: dict, data_directory: Path): def __init__(self, config: dict, data_directory: Path):
self.hostname = config['server_address'] self.hostname = config["server_address"]
self.username = config['username'] self.username = config["username"]
self.password = config['password'] self.password = config["password"]
self.disable_cert_verify = config.get('disable_cert_verify') self.disable_cert_verify = config.get("disable_cert_verify")
# TODO support XML | JSON # TODO support XML | JSON
@@ -53,29 +49,29 @@ class SubsonicAdapter(Adapter):
def can_service_requests(self) -> bool: def can_service_requests(self) -> bool:
try: try:
# Try to ping the server with a timeout of 2 seconds. # 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 return True
except Exception: except Exception:
logging.exception(f'Could not connect to {self.hostname}') logging.exception(f"Could not connect to {self.hostname}")
return False return False
# Helper mothods for making requests # Helper mothods for making requests
# ========================================================================= # =========================================================================
def _get_params(self) -> Dict[str, str]: def _get_params(self) -> Dict[str, str]:
""" """
Gets the parameters that are needed for all requests to the Subsonic Gets the parameters that are needed for all requests to the Subsonic API. See
API. See Subsonic API Introduction for details. Subsonic API Introduction for details.
""" """
return { return {
'u': self.username, "u": self.username,
'p': self.password, "p": self.password,
'c': 'Sublime Music', "c": "Sublime Music",
'f': 'json', "f": "json",
'v': '1.15.0', "v": "1.15.0",
} }
def _make_url(self, endpoint: str) -> str: def _make_url(self, endpoint: str) -> str:
return f'{self.hostname}/rest/{endpoint}.view' return f"{self.hostname}/rest/{endpoint}.view"
def _get( def _get(
self, self,
@@ -84,13 +80,14 @@ class SubsonicAdapter(Adapter):
**params, **params,
) -> Any: ) -> Any:
params = {**self._get_params(), **params} 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( logging.info(
"SUBLIME_MUSIC_DEBUG_DELAY enabled. Pausing for " "SUBLIME_MUSIC_DEBUG_DELAY enabled. Pausing for "
f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds.") f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds."
sleep(float(os.environ['SUBLIME_MUSIC_DEBUG_DELAY'])) )
sleep(float(os.environ["SUBLIME_MUSIC_DEBUG_DELAY"]))
# Deal with datetime parameters (convert to milliseconds since 1970) # Deal with datetime parameters (convert to milliseconds since 1970)
for k, v in params.items(): for k, v in params.items():
@@ -98,21 +95,18 @@ class SubsonicAdapter(Adapter):
params[k] = int(v.timestamp() * 1000) params[k] = int(v.timestamp() * 1000)
if self._is_mock: if self._is_mock:
logging.debug('Using mock data') logging.debug("Using mock data")
return self._get_mock_data() return self._get_mock_data()
result = requests.get( result = requests.get(
url, url, params=params, verify=not self.disable_cert_verify, timeout=timeout
params=params,
verify=not self.disable_cert_verify,
timeout=timeout,
) )
# TODO (#122): make better # TODO (#122): make better
if result.status_code != 200: 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 return result
def _get_json( def _get_json(
@@ -122,27 +116,27 @@ class SubsonicAdapter(Adapter):
**params: Union[None, str, datetime, int, Sequence[int]], **params: Union[None, str, datetime, int, Sequence[int]],
) -> Response: ) -> Response:
""" """
Make a get request to a *Sonic REST API. Handle all types of errors Make a get request to a *Sonic REST API. Handle all types of errors including
including *Sonic ``<error>`` responses. *Sonic ``<error>`` responses.
:returns: a dictionary of the subsonic response. :returns: a dictionary of the subsonic response.
:raises Exception: needs some work TODO :raises Exception: needs some work TODO
""" """
result = self._get(url, timeout=timeout, **params) 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 # TODO (#122): make better
if not subsonic_response: 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 = ( code, message = (
subsonic_response['error'].get('code'), subsonic_response["error"].get("code"),
subsonic_response['error'].get('message'), 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) return Response.from_dict(subsonic_response)
# Helper Methods for Testing # Helper Methods for Testing
@@ -172,21 +166,15 @@ class SubsonicAdapter(Adapter):
can_get_playlists = True can_get_playlists = True
def get_playlists(self) -> Sequence[API.Playlist]: 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: if not response:
return [] return []
return response.playlist return response.playlist
can_get_playlist_details = True can_get_playlist_details = True
def get_playlist_details( def get_playlist_details(self, playlist_id: str,) -> API.PlaylistDetails:
self, result = self._get_json(self._make_url("getPlaylist"), id=playlist_id,).playlist
playlist_id: str,
) -> API.PlaylistDetails:
result = self._get_json(
self._make_url('getPlaylist'),
id=playlist_id,
).playlist
# TODO better error # TODO better error
assert result, f'Error getting playlist {playlist_id}' assert result, f"Error getting playlist {playlist_id}"
return result return result

View File

@@ -18,15 +18,13 @@ from .. import api_objects as SublimeAPI
# Translation map # Translation map
extra_translation_map = { extra_translation_map = {
datetime: datetime: (lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%f%z") if s else None),
(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), timedelta: (lambda s: timedelta(seconds=s) if s else None),
} }
for type_, translation_function in extra_translation_map.items(): for type_, translation_function in extra_translation_map.items():
dataclasses_json.cfg.global_config.decoders[type_] = translation_function dataclasses_json.cfg.global_config.decoders[type_] = translation_function
dataclasses_json.cfg.global_config.decoders[ dataclasses_json.cfg.global_config.decoders[Optional[type_]] = translation_function
Optional[type_]] = translation_function
@dataclass_json(letter_case=LetterCase.CAMEL) @dataclass_json(letter_case=LetterCase.CAMEL)
@@ -81,8 +79,7 @@ class Playlist(SublimeAPI.Playlist):
class PlaylistWithSongs(SublimeAPI.PlaylistDetails): class PlaylistWithSongs(SublimeAPI.PlaylistDetails):
id: str id: str
name: str name: str
songs: List[Song] = field( songs: List[Song] = field(default_factory=list, metadata=config(field_name="entry"))
default_factory=list, metadata=config(field_name='entry'))
song_count: int = field(default=0) song_count: int = field(default=0)
duration: timedelta = field(default=timedelta()) duration: timedelta = field(default=timedelta())
created: Optional[datetime] = None created: Optional[datetime] = None
@@ -96,8 +93,9 @@ class PlaylistWithSongs(SublimeAPI.PlaylistDetails):
self.song_count = self.song_count or len(self.songs) self.song_count = self.song_count or len(self.songs)
self.duration = self.duration or timedelta( self.duration = self.duration or timedelta(
seconds=sum( seconds=sum(
s.duration.total_seconds() if s.duration else 0 s.duration.total_seconds() if s.duration else 0 for s in self.songs
for s in self.songs)) )
)
@dataclass @dataclass
@@ -107,9 +105,8 @@ class Playlists(DataClassJsonMixin):
@dataclass @dataclass
class Response(DataClassJsonMixin): class Response(DataClassJsonMixin):
""" """The base Subsonic response object."""
The base Subsonic response object.
"""
song: Optional[Song] = None song: Optional[Song] = None
playlists: Optional[Playlists] = None playlists: Optional[Playlists] = None
playlist: Optional[PlaylistWithSongs] = None playlist: Optional[PlaylistWithSongs] = None

View File

@@ -9,22 +9,27 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
try: try:
import osxmmkeys import osxmmkeys
tap_imported = True tap_imported = True
except Exception: except Exception:
tap_imported = False tap_imported = False
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
try: try:
gi.require_version('Notify', '0.7') gi.require_version("Notify", "0.7")
from gi.repository import Notify from gi.repository import Notify
glib_notify_exists = True glib_notify_exists = True
except Exception: except Exception:
# I really don't care what kind of exception it is, all that matters is the # I really don't care what kind of exception it is, all that matters is the
# import failed for some reason. # import failed for some reason.
logging.warning( 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 glib_notify_exists = False
from .adapters import AdapterManager from .adapters import AdapterManager
@@ -44,14 +49,14 @@ class SublimeMusicApp(Gtk.Application):
def __init__(self, config_file: Path): def __init__(self, config_file: Path):
super().__init__(application_id="com.sumnerevans.sublimemusic") super().__init__(application_id="com.sumnerevans.sublimemusic")
if glib_notify_exists: if glib_notify_exists:
Notify.init('Sublime Music') Notify.init("Sublime Music")
self.window: Optional[Gtk.Window] = None self.window: Optional[Gtk.Window] = None
self.app_config = AppConfiguration.load_from_file(config_file) self.app_config = AppConfiguration.load_from_file(config_file)
self.player = None self.player = None
self.dbus_manager: Optional[DBusManager] = 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): def do_startup(self):
Gtk.Application.do_startup(self) Gtk.Application.do_startup(self)
@@ -61,40 +66,39 @@ class SublimeMusicApp(Gtk.Application):
if type(parameter_type) == str: if type(parameter_type) == str:
parameter_type = GLib.VariantType(parameter_type) parameter_type = GLib.VariantType(parameter_type)
action = Gio.SimpleAction.new(name, parameter_type) action = Gio.SimpleAction.new(name, parameter_type)
action.connect('activate', fn) action.connect("activate", fn)
self.add_action(action) self.add_action(action)
# Add action for menu items. # Add action for menu items.
add_action('configure-servers', self.on_configure_servers) add_action("configure-servers", self.on_configure_servers)
add_action('settings', self.on_settings) add_action("settings", self.on_settings)
# Add actions for player controls # Add actions for player controls
add_action('play-pause', self.on_play_pause) add_action("play-pause", self.on_play_pause)
add_action('next-track', self.on_next_track) add_action("next-track", self.on_next_track)
add_action('prev-track', self.on_prev_track) add_action("prev-track", self.on_prev_track)
add_action('repeat-press', self.on_repeat_press) add_action("repeat-press", self.on_repeat_press)
add_action('shuffle-press', self.on_shuffle_press) add_action("shuffle-press", self.on_shuffle_press)
# Navigation actions. # Navigation actions.
add_action('play-next', self.on_play_next, parameter_type='as') 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("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-album", self.on_go_to_album, parameter_type="s")
add_action('go-to-artist', self.on_go_to_artist, 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("browse-to", self.browse_to, parameter_type="s")
add_action( add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
'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( add_action(
'update-play-queue-from-server', "update-play-queue-from-server",
lambda a, p: self.update_play_state_from_server(), lambda a, p: self.update_play_state_from_server(),
) )
if tap_imported: if tap_imported:
self.tap = osxmmkeys.Tap() self.tap = osxmmkeys.Tap()
self.tap.on('play_pause', self.on_play_pause) self.tap.on("play_pause", self.on_play_pause)
self.tap.on('next_track', self.on_next_track) self.tap.on("next_track", self.on_next_track)
self.tap.on('prev_track', self.on_prev_track) self.tap.on("prev_track", self.on_prev_track)
self.tap.start() self.tap.start()
def do_activate(self): def do_activate(self):
@@ -111,26 +115,25 @@ class SublimeMusicApp(Gtk.Application):
# window. # window.
css_provider = Gtk.CssProvider() css_provider = Gtk.CssProvider()
css_provider.load_from_path( 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() context = Gtk.StyleContext()
screen = Gdk.Screen.get_default() screen = Gdk.Screen.get_default()
context.add_provider_for_screen( 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( self.window.stack.connect(
'notify::visible-child', "notify::visible-child", self.on_stack_change,
self.on_stack_change,
) )
self.window.connect('song-clicked', self.on_song_clicked) self.window.connect("song-clicked", self.on_song_clicked)
self.window.connect('songs-removed', self.on_songs_removed) self.window.connect("songs-removed", self.on_songs_removed)
self.window.connect('refresh-window', self.on_refresh_window) self.window.connect("refresh-window", self.on_refresh_window)
self.window.connect('go-to', self.on_window_go_to) self.window.connect("go-to", self.on_window_go_to)
self.window.connect('key-press-event', self.on_window_key_press) 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("song-scrub", self.on_song_scrub)
self.window.player_controls.connect( self.window.player_controls.connect("device-update", self.on_device_update)
'device-update', self.on_device_update) self.window.player_controls.connect("volume-change", self.on_volume_change)
self.window.player_controls.connect(
'volume-change', self.on_volume_change)
self.window.show_all() self.window.show_all()
self.window.present() self.window.present()
@@ -155,8 +158,11 @@ class SublimeMusicApp(Gtk.Application):
self.should_scrobble_song = False self.should_scrobble_song = False
def time_observer(value: Optional[float]): def time_observer(value: Optional[float]):
if (self.loading_state or not self.window if (
or not self.app_config.state.current_song): self.loading_state
or not self.window
or not self.app_config.state.current_song
):
return return
if value is None: if value is None:
@@ -178,10 +184,11 @@ class SublimeMusicApp(Gtk.Application):
self.should_scrobble_song = False self.should_scrobble_song = False
def on_track_end(): def on_track_end():
at_end = self.app_config.state.current_song_index == len( at_end = (
self.app_config.state.play_queue) - 1 self.app_config.state.current_song_index
no_repeat = ( == len(self.app_config.state.play_queue) - 1
self.app_config.state.repeat_type == RepeatType.NO_REPEAT) )
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
if at_end and no_repeat: if at_end and no_repeat:
self.app_config.state.playing = False self.app_config.state.playing = False
self.app_config.state.current_song_index = -1 self.app_config.state.current_song_index = -1
@@ -192,32 +199,26 @@ class SublimeMusicApp(Gtk.Application):
@dbus_propagate(self) @dbus_propagate(self)
def on_player_event(event: PlayerEvent): 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 self.app_config.state.playing = event.value
elif event.name == 'volume_change': elif event.name == "volume_change":
self.app_config.state.volume = event.value self.app_config.state.volume = event.value
GLib.idle_add(self.update_window) GLib.idle_add(self.update_window)
self.mpv_player = MPVPlayer( self.mpv_player = MPVPlayer(
time_observer, time_observer, on_track_end, on_player_event, self.app_config,
on_track_end,
on_player_event,
self.app_config,
) )
self.chromecast_player = ChromecastPlayer( self.chromecast_player = ChromecastPlayer(
time_observer, time_observer, on_track_end, on_player_event, self.app_config,
on_track_end,
on_player_event,
self.app_config,
) )
self.player = self.mpv_player 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) # TODO (#120)
pass 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. # Need to do this after we set the current device.
self.player.volume = self.app_config.state.volume self.player.volume = self.app_config.state.volume
@@ -231,8 +232,7 @@ class SublimeMusicApp(Gtk.Application):
self.dbus_manager.property_diff() self.dbus_manager.property_diff()
# ########## DBUS MANAGMENT ########## # # ########## DBUS MANAGMENT ########## #
def do_dbus_register( def do_dbus_register(self, connection: Gio.DBusConnection, path: str) -> bool:
self, connection: Gio.DBusConnection, path: str) -> bool:
self.dbus_manager = DBusManager( self.dbus_manager = DBusManager(
connection, connection,
self.on_dbus_method_call, self.on_dbus_method_call,
@@ -264,9 +264,7 @@ class SublimeMusicApp(Gtk.Application):
# it could be a directory. # it could be a directory.
assert self.app_config.state.current_song.duration is not None assert self.app_config.state.current_song.duration is not None
self.on_song_scrub( self.on_song_scrub(
None, None, new_seconds / self.app_config.state.current_song.duration * 100,
new_seconds / self.app_config.state.current_song.duration
* 100,
) )
def set_pos_fn(track_id: str, position: float = 0): def set_pos_fn(track_id: str, position: float = 0):
@@ -274,12 +272,13 @@ class SublimeMusicApp(Gtk.Application):
self.on_play_pause() self.on_play_pause()
pos_seconds = position / second_microsecond_conversion pos_seconds = position / second_microsecond_conversion
self.app_config.state.song_progress = pos_seconds 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 # Find the (N-1)th time that the track id shows up in the list. (N
# is the -*** suffix on the track id.) # is the -*** suffix on the track id.)
song_index = [ 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 if x == track_id
][int(occurrence) or 0] ][int(occurrence) or 0]
@@ -291,42 +290,42 @@ class SublimeMusicApp(Gtk.Application):
if len(track_ids) == 0: if len(track_ids) == 0:
# We are lucky, just return an empty list. # 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 # Have to calculate all of the metadatas so that we can deal with
# repeat song IDs. # repeat song IDs.
metadatas: Iterable[Any] = [ metadatas: Iterable[Any] = [
self.dbus_manager.get_mpris_metadata( self.dbus_manager.get_mpris_metadata(
i, i, self.app_config.state.play_queue,
self.app_config.state.play_queue, )
) for i in range(len(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. # Get rid of all of the tracks that were not requested.
metadatas = list( 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) assert len(metadatas) == len(track_ids)
# Sort them so they get returned in the same order as they were # Sort them so they get returned in the same order as they were
# requested. # requested.
metadatas = sorted( 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 # Turn them into dictionaries that can actually be serialized into
# a GLib.Variant. # a GLib.Variant.
metadatas = map( metadatas = map(
lambda m: {k: DBusManager.to_variant(v) lambda m: {k: DBusManager.to_variant(v) for k, v in m.items()},
for k, v in m.items()},
metadatas, metadatas,
) )
return GLib.Variant('(aa{sv})', (list(metadatas), )) return GLib.Variant("(aa{sv})", (list(metadatas),))
def activate_playlist(playlist_id: str): def activate_playlist(playlist_id: str):
playlist_id = playlist_id.split('/')[-1] playlist_id = playlist_id.split("/")[-1]
playlist = AdapterManager.get_playlist_details( playlist = AdapterManager.get_playlist_details(playlist_id).result()
playlist_id).result()
# Calculate the song id to play. # Calculate the song id to play.
song_idx = 0 song_idx = 0
@@ -337,46 +336,44 @@ class SublimeMusicApp(Gtk.Application):
None, None,
song_idx, song_idx,
[s.id for s in playlist.songs], [s.id for s in playlist.songs],
{'active_playlist_id': playlist_id}, {"active_playlist_id": playlist_id},
) )
def get_playlists( def get_playlists(
index: int, index: int, max_count: int, order: str, reverse_order: bool,
max_count: int,
order: str,
reverse_order: bool,
) -> GLib.Variant: ) -> GLib.Variant:
playlists_result = AdapterManager.get_playlists() playlists_result = AdapterManager.get_playlists()
if not playlists_result.data_is_available: if not playlists_result.data_is_available:
# We don't want to wait for the response in this case, so just # We don't want to wait for the response in this case, so just
# return an empty array. # return an empty array.
return GLib.Variant('(a(oss))', ([], )) return GLib.Variant("(a(oss))", ([],))
playlists = list(playlists_result.result()) playlists = list(playlists_result.result())
sorters = { sorters = {
'Alphabetical': lambda p: p.name, "Alphabetical": lambda p: p.name,
'Created': lambda p: p.created, "Created": lambda p: p.created,
'Modified': lambda p: p.changed, "Modified": lambda p: p.changed,
} }
playlists.sort( playlists.sort(
key=sorters.get(order, lambda p: p), key=sorters.get(order, lambda p: p), reverse=reverse_order,
reverse=reverse_order,
) )
def make_playlist_tuple(p: Playlist) -> GLib.Variant: def make_playlist_tuple(p: Playlist) -> GLib.Variant:
cover_art_filename = CacheManager.get_cover_art_filename( cover_art_filename = CacheManager.get_cover_art_filename(
p.cover_art, p.cover_art, allow_download=False,
allow_download=False,
).result() ).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( return GLib.Variant(
'(a(oss))', ( "(a(oss))",
(
[ [
make_playlist_tuple(p) make_playlist_tuple(p)
for p in playlists[index:(index + max_count)] for p in playlists[index : (index + max_count)]
], )) ],
),
)
def play(): def play():
if not self.app_config.state.playing: if not self.app_config.state.playing:
@@ -387,36 +384,34 @@ class SublimeMusicApp(Gtk.Application):
self.on_play_pause() self.on_play_pause()
method_call_map: Dict[str, Dict[str, Any]] = { method_call_map: Dict[str, Dict[str, Any]] = {
'org.mpris.MediaPlayer2': { "org.mpris.MediaPlayer2": {
'Raise': self.window and self.window.present, "Raise": self.window and self.window.present,
'Quit': self.window and self.window.destroy, "Quit": self.window and self.window.destroy,
}, },
'org.mpris.MediaPlayer2.Player': { "org.mpris.MediaPlayer2.Player": {
'Next': self.on_next_track, "Next": self.on_next_track,
'Previous': self.on_prev_track, "Previous": self.on_prev_track,
'Pause': pause, "Pause": pause,
'PlayPause': self.on_play_pause, "PlayPause": self.on_play_pause,
'Stop': pause, "Stop": pause,
'Play': play, "Play": play,
'Seek': seek_fn, "Seek": seek_fn,
'SetPosition': set_pos_fn, "SetPosition": set_pos_fn,
}, },
'org.mpris.MediaPlayer2.TrackList': { "org.mpris.MediaPlayer2.TrackList": {
'GoTo': set_pos_fn, "GoTo": set_pos_fn,
'GetTracksMetadata': get_tracks_metadata, "GetTracksMetadata": get_tracks_metadata,
# 'RemoveTrack': remove_track, # 'RemoveTrack': remove_track,
}, },
'org.mpris.MediaPlayer2.Playlists': { "org.mpris.MediaPlayer2.Playlists": {
'ActivatePlaylist': activate_playlist, "ActivatePlaylist": activate_playlist,
'GetPlaylists': get_playlists, "GetPlaylists": get_playlists,
}, },
} }
method_fn = method_call_map.get(interface, {}).get(method) method_fn = method_call_map.get(interface, {}).get(method)
if method_fn is None: if method_fn is None:
logging.warning( logging.warning(f"Unknown/unimplemented method: {interface}.{method}.")
f'Unknown/unimplemented method: {interface}.{method}.') invocation.return_value(method_fn(*params) if callable(method_fn) else None)
invocation.return_value(
method_fn(*params) if callable(method_fn) else None)
def on_dbus_set_property( def on_dbus_set_property(
self, self,
@@ -428,9 +423,9 @@ class SublimeMusicApp(Gtk.Application):
value: GLib.Variant, value: GLib.Variant,
): ):
def change_loop(new_loop_status: GLib.Variant): def change_loop(new_loop_status: GLib.Variant):
self.app_config.state.repeat_type = ( self.app_config.state.repeat_type = RepeatType.from_mpris_loop_status(
RepeatType.from_mpris_loop_status( new_loop_status.get_string()
new_loop_status.get_string())) )
self.update_window() self.update_window()
def set_shuffle(new_val: GLib.Variant): 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) self.on_volume_change(None, new_val.get_double() * 100)
setter_map: Dict[str, Dict[str, Any]] = { setter_map: Dict[str, Dict[str, Any]] = {
'org.mpris.MediaPlayer2.Player': { "org.mpris.MediaPlayer2.Player": {
'LoopStatus': change_loop, "LoopStatus": change_loop,
'Rate': lambda _: None, "Rate": lambda _: None,
'Shuffle': set_shuffle, "Shuffle": set_shuffle,
'Volume': set_volume, "Volume": set_volume,
} }
} }
setter = setter_map.get(interface, {}).get(property_name) setter = setter_map.get(interface, {}).get(property_name)
if setter is None: if setter is None:
logging.warning('Set: Unknown property: {property_name}.') logging.warning("Set: Unknown property: {property_name}.")
return return
if callable(setter): if callable(setter):
setter(value) setter(value)
@@ -459,10 +454,7 @@ class SublimeMusicApp(Gtk.Application):
# ########## ACTION HANDLERS ########## # # ########## ACTION HANDLERS ########## #
@dbus_propagate() @dbus_propagate()
def on_refresh_window( def on_refresh_window(
self, self, _, state_updates: Dict[str, Any], force: bool = False,
_,
state_updates: Dict[str, Any],
force: bool = False,
): ):
for k, v in state_updates.items(): for k, v in state_updates.items():
setattr(self.app_config.state, k, v) setattr(self.app_config.state, k, v)
@@ -476,32 +468,34 @@ class SublimeMusicApp(Gtk.Application):
dialog = SettingsDialog(self.window, self.app_config) dialog = SettingsDialog(self.window, self.app_config)
result = dialog.run() result = dialog.run()
if result == Gtk.ResponseType.OK: if result == Gtk.ResponseType.OK:
self.app_config.port_number = int( self.app_config.port_number = int(dialog.data["port_number"].get_text())
dialog.data['port_number'].get_text()) self.app_config.always_stream = dialog.data["always_stream"].get_active()
self.app_config.always_stream = dialog.data[
'always_stream'].get_active()
self.app_config.download_on_stream = dialog.data[ 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[ self.app_config.song_play_notification = dialog.data[
'song_play_notification'].get_active() "song_play_notification"
self.app_config.serve_over_lan = dialog.data[ ].get_active()
'serve_over_lan'].get_active() self.app_config.serve_over_lan = dialog.data["serve_over_lan"].get_active()
self.app_config.prefetch_amount = dialog.data[ 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[ 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( 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.app_config.save()
self.reset_state() self.reset_state()
dialog.destroy() dialog.destroy()
def on_window_go_to(self, win: Any, action: str, value: str): def on_window_go_to(self, win: Any, action: str, value: str):
{ {
'album': self.on_go_to_album, "album": self.on_go_to_album,
'artist': self.on_go_to_artist, "artist": self.on_go_to_artist,
'playlist': self.on_go_to_playlist, "playlist": self.on_go_to_playlist,
}[action](None, GLib.Variant('s', value)) }[action](None, GLib.Variant("s", value))
@dbus_propagate() @dbus_propagate()
def on_play_pause(self, *args): def on_play_pause(self, *args):
@@ -523,8 +517,10 @@ class SublimeMusicApp(Gtk.Application):
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG: if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
song_index_to_play = self.app_config.state.current_song_index song_index_to_play = self.app_config.state.current_song_index
# Wrap around the play queue if at the end. # Wrap around the play queue if at the end.
elif self.app_config.state.current_song_index == len( elif (
self.app_config.state.play_queue) - 1: self.app_config.state.current_song_index
== len(self.app_config.state.play_queue) - 1
):
# This may happen due to D-Bus. # This may happen due to D-Bus.
if self.app_config.state.repeat_type == RepeatType.NO_REPEAT: if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
return return
@@ -545,8 +541,8 @@ class SublimeMusicApp(Gtk.Application):
song_index_to_play = 0 song_index_to_play = 0
else: else:
song_index_to_play = ( song_index_to_play = (
self.app_config.state.current_song_index - 1) % len( self.app_config.state.current_song_index - 1
self.app_config.state.play_queue) ) % len(self.app_config.state.play_queue)
else: else:
# Go back to the beginning of the song. # Go back to the beginning of the song.
song_index_to_play = self.app_config.state.current_song_index song_index_to_play = self.app_config.state.current_song_index
@@ -556,8 +552,7 @@ class SublimeMusicApp(Gtk.Application):
@dbus_propagate() @dbus_propagate()
def on_repeat_press(self, *args): def on_repeat_press(self, *args):
# Cycle through the repeat types. # Cycle through the repeat types.
new_repeat_type = RepeatType( new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
(self.app_config.state.repeat_type.value + 1) % 3)
self.app_config.state.repeat_type = new_repeat_type self.app_config.state.repeat_type = new_repeat_type
self.update_window() self.update_window()
@@ -565,22 +560,26 @@ class SublimeMusicApp(Gtk.Application):
def on_shuffle_press(self, *args): def on_shuffle_press(self, *args):
if self.app_config.state.shuffle_on: if self.app_config.state.shuffle_on:
# Revert to the old play queue. # Revert to the old play queue.
self.app_config.state.current_song_index = ( self.app_config.state.current_song_index = self.app_config.state.old_play_queue.index(
self.app_config.state.old_play_queue.index( self.app_config.state.current_song.id
self.app_config.state.current_song.id)) )
self.app_config.state.play_queue = ( self.app_config.state.play_queue = (
self.app_config.state.old_play_queue.copy()) self.app_config.state.old_play_queue.copy()
)
else: else:
self.app_config.state.old_play_queue = ( 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. # Remove the current song, then shuffle and put the song back.
song_id = self.app_config.state.current_song.id song_id = self.app_config.state.current_song.id
del self.app_config.state.play_queue[ 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) random.shuffle(self.app_config.state.play_queue)
self.app_config.state.play_queue = ( self.app_config.state.play_queue = [
[song_id] + 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.current_song_index = 0
self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on 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 insert_at = self.app_config.state.current_song_index + 1
self.app_config.state.play_queue = ( 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.app_config.state.old_play_queue.extend(song_ids)
self.update_window() self.update_window()
@@ -613,43 +614,43 @@ class SublimeMusicApp(Gtk.Application):
if len(album.child) > 0: if len(album.child) > 0:
album = album.child[0] album = album.child[0]
if album.get('year'): if year := album.get("year"):
self.app_config.state.current_album_sort = 'byYear' self.app_config.state.current_album_sort = "byYear"
self.app_config.state.current_album_from_year = album.year self.app_config.state.current_album_from_year = year
self.app_config.state.current_album_to_year = album.year self.app_config.state.current_album_to_year = year
elif album.get('genre'): elif genre := album.get("genre"):
self.app_config.state.current_album_sort = 'byGenre' self.app_config.state.current_album_sort = "byGenre"
self.app_config.state.current_album_genre = album.genre self.app_config.state.current_album_genre = genre
else: else:
dialog = Gtk.MessageDialog( dialog = Gtk.MessageDialog(
transient_for=self.window, transient_for=self.window,
message_type=Gtk.MessageType.ERROR, message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK, buttons=Gtk.ButtonsType.OK,
text='Could not go to album', text="Could not go to album",
) )
dialog.format_secondary_markup( dialog.format_secondary_markup(
'Could not go to the album because it does not have a year or ' "Could not go to the album because it does not have a year or " "genre."
'genre.') )
dialog.run() dialog.run()
dialog.destroy() dialog.destroy()
return 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.app_config.state.selected_album_id = album_id.get_string()
self.update_window(force=True) self.update_window(force=True)
def on_go_to_artist(self, action: Any, artist_id: GLib.Variant): 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.app_config.state.selected_artist_id = artist_id.get_string()
self.update_window() self.update_window()
def browse_to(self, action: Any, item_id: GLib.Variant): 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.app_config.state.selected_browse_element_id = item_id.get_string()
self.update_window() self.update_window()
def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant): 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.app_config.state.selected_playlist_id = playlist_id.get_string()
self.update_window() self.update_window()
@@ -673,9 +674,7 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.save() self.app_config.save()
def on_connected_server_changed( def on_connected_server_changed(
self, self, action: Any, current_server_index: int,
action: Any,
current_server_index: int,
): ):
if self.app_config.server: if self.app_config.server:
self.app_config.save() self.app_config.save()
@@ -710,18 +709,13 @@ class SublimeMusicApp(Gtk.Application):
# previous one. # previous one.
old_play_queue = song_queue.copy() old_play_queue = song_queue.copy()
if metadata.get('force_shuffle_state') is not None: if (force_shuffle := metadata.get("force_shuffle_state")) is not None:
self.app_config.state.shuffle_on = metadata['force_shuffle_state'] 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")
self.app_config.state.active_playlist_id = metadata.get(
'active_playlist_id')
else:
self.app_config.state.active_playlist_id = None
# If shuffle is enabled, then shuffle the playlist. # If shuffle is enabled, then shuffle the playlist.
if self.app_config.state.shuffle_on and not metadata.get( if self.app_config.state.shuffle_on and not metadata.get("no_reshuffle"):
'no_reshuffle'):
song_id = song_queue[song_index] song_id = song_queue[song_index]
del 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 # Determine how many songs before the currently playing one were also
# deleted. # deleted.
before_current = [ 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 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.app_config.state.current_song_index -= len(before_current)
self.play_song( self.play_song(
self.app_config.state.current_song_index, self.app_config.state.current_song_index, reset=True,
reset=True,
) )
else: else:
self.app_config.state.current_song_index -= len(before_current) 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 # a duration, but the Child object has `duration` optional because
# it could be a directory. # it could be a directory.
assert self.app_config.state.current_song.duration is not None assert self.app_config.state.current_song.duration is not None
new_time = self.app_config.state.current_song.duration * ( new_time = self.app_config.state.current_song.duration * (scrub_value / 100)
scrub_value / 100)
self.app_config.state.song_progress = new_time self.app_config.state.song_progress = new_time
self.window.player_controls.update_scrubber( self.window.player_controls.update_scrubber(
@@ -806,7 +799,7 @@ class SublimeMusicApp(Gtk.Application):
self.update_window() self.update_window()
if device_uuid == 'this device': if device_uuid == "this device":
self.player = self.mpv_player self.player = self.mpv_player
else: else:
self.chromecast_player.set_playing_chromecast(device_uuid) 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.player.volume = self.app_config.state.volume
self.update_window() self.update_window()
def on_window_key_press( def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey,) -> bool:
self,
window: Gtk.Window,
event: Gdk.EventKey,
) -> bool:
# Need to use bitwise & here to see if CTRL is pressed. # Need to use bitwise & here to see if CTRL is pressed.
if (event.keyval == 102 if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
and event.state & Gdk.ModifierType.CONTROL_MASK):
# Ctrl + F # Ctrl + F
window.search_entry.grab_focus() window.search_entry.grab_focus()
return False return False
@@ -857,7 +845,7 @@ class SublimeMusicApp(Gtk.Application):
return False return False
def on_app_shutdown(self, app: 'SublimeMusicApp'): def on_app_shutdown(self, app: "SublimeMusicApp"):
if glib_notify_exists: if glib_notify_exists:
Notify.uninit() Notify.uninit()
@@ -882,9 +870,8 @@ class SublimeMusicApp(Gtk.Application):
def show_configure_servers_dialog(self): def show_configure_servers_dialog(self):
"""Show the Connect to Server dialog.""" """Show the Connect to Server dialog."""
dialog = ConfigureServersDialog(self.window, self.app_config) dialog = ConfigureServersDialog(self.window, self.app_config)
dialog.connect('server-list-changed', self.on_server_list_changed) dialog.connect("server-list-changed", self.on_server_list_changed)
dialog.connect( dialog.connect("connected-server-changed", self.on_connected_server_changed)
'connected-server-changed', self.on_connected_server_changed)
dialog.run() dialog.run()
dialog.destroy() dialog.destroy()
@@ -912,11 +899,13 @@ class SublimeMusicApp(Gtk.Application):
progress_diff = 15 progress_diff = 15
if self.app_config.state.song_progress: if self.app_config.state.song_progress:
progress_diff = abs( progress_diff = abs(
self.app_config.state.song_progress self.app_config.state.song_progress - new_song_progress
- new_song_progress) )
if (self.app_config.state.play_queue == new_play_queue if (
and self.app_config.state.current_song): 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 song_id = self.app_config.state.current_song.id
if song_id == new_current_song_id and progress_diff < 15: if song_id == new_current_song_id and progress_diff < 15:
return return
@@ -925,14 +914,18 @@ class SublimeMusicApp(Gtk.Application):
transient_for=self.window, transient_for=self.window,
message_type=Gtk.MessageType.INFO, message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.YES_NO, buttons=Gtk.ButtonsType.YES_NO,
text='Resume Playback?', text="Resume Playback?",
) )
dialog.format_secondary_markup( dialog.format_secondary_markup(
'Do you want to resume the play queue saved by ' "Do you want to resume the play queue saved by "
+ str(play_queue.changedBy) + ' at ' + str(play_queue.changedBy)
+ play_queue.changed.astimezone( + " at "
tz=None).strftime('%H:%M on %Y-%m-%d') + '?') + play_queue.changed.astimezone(tz=None).strftime(
"%H:%M on %Y-%m-%d"
)
+ "?"
)
result = dialog.run() result = dialog.run()
dialog.destroy() dialog.destroy()
if result != Gtk.ResponseType.YES: 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.play_queue = new_play_queue
self.app_config.state.song_progress = play_queue.position / 1000 self.app_config.state.song_progress = play_queue.position / 1000
self.app_config.state.current_song_index = ( self.app_config.state.current_song_index = self.app_config.state.play_queue.index(
self.app_config.state.play_queue.index(new_current_song_id)) new_current_song_id
)
self.player.reset() self.player.reset()
self.update_window() self.update_window()
@@ -951,8 +945,7 @@ class SublimeMusicApp(Gtk.Application):
self.on_play_pause() self.on_play_pause()
play_queue_future = CacheManager.get_play_queue() play_queue_future = CacheManager.get_play_queue()
play_queue_future.add_done_callback( play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f))
lambda f: GLib.idle_add(do_update, f))
song_playing_order_token = 0 song_playing_order_token = 0
@@ -968,8 +961,7 @@ class SublimeMusicApp(Gtk.Application):
@dbus_propagate(self) @dbus_propagate(self)
def do_play_song(song: Child): def do_play_song(song: Child):
uri, stream = CacheManager.get_song_filename_or_stream( uri, stream = CacheManager.get_song_filename_or_stream(
song, song, force_stream=self.app_config.always_stream,
force_stream=self.app_config.always_stream,
) )
# Prevent it from doing the thing where it continually loads # Prevent it from doing the thing where it continually loads
# songs when it has to download. # songs when it has to download.
@@ -984,24 +976,21 @@ class SublimeMusicApp(Gtk.Application):
if glib_notify_exists: if glib_notify_exists:
notification_lines = [] notification_lines = []
if song.album: if song.album:
notification_lines.append(f'<i>{song.album}</i>') notification_lines.append(f"<i>{song.album}</i>")
if song.artist: if song.artist:
notification_lines.append(song.artist) notification_lines.append(song.artist)
song_notification = Notify.Notification.new( song_notification = Notify.Notification.new(
song.title, song.title, "\n".join(notification_lines),
'\n'.join(notification_lines),
) )
song_notification.add_action( song_notification.add_action(
'clicked', "clicked",
'Open Sublime Music', "Open Sublime Music",
lambda *a: self.window.present() lambda *a: self.window.present() if self.window else None,
if self.window else None,
) )
song_notification.show() song_notification.show()
def on_cover_art_download_complete( def on_cover_art_download_complete(
cover_art_filename: str, cover_art_filename: str, order_token: int,
order_token: int,
): ):
if order_token != self.song_playing_order_token: if order_token != self.song_playing_order_token:
return return
@@ -1010,51 +999,54 @@ class SublimeMusicApp(Gtk.Application):
# the notification. # the notification.
song_notification.set_image_from_pixbuf( song_notification.set_image_from_pixbuf(
GdkPixbuf.Pixbuf.new_from_file_at_scale( GdkPixbuf.Pixbuf.new_from_file_at_scale(
cover_art_filename, 70, 70, True)) cover_art_filename, 70, 70, True
)
)
song_notification.show() song_notification.show()
def get_cover_art_filename( def get_cover_art_filename(order_token: int) -> Tuple[str, int]:
order_token: int) -> Tuple[str, int]:
return ( return (
CacheManager.get_cover_art_filename( CacheManager.get_cover_art_filename(
song.coverArt).result(), song.coverArt
).result(),
order_token, order_token,
) )
self.song_playing_order_token += 1 self.song_playing_order_token += 1
cover_art_future = CacheManager.create_future( cover_art_future = CacheManager.create_future(
get_cover_art_filename, get_cover_art_filename, self.song_playing_order_token,
self.song_playing_order_token,
) )
cover_art_future.add_done_callback( cover_art_future.add_done_callback(
lambda f: on_cover_art_download_complete( lambda f: on_cover_art_download_complete(*f.result())
*f.result())) )
if sys.platform == 'darwin': if sys.platform == "darwin":
notification_lines = [] notification_lines = []
if song.album: if song.album:
notification_lines.append(song.album) notification_lines.append(song.album)
if song.artist: if song.artist:
notification_lines.append(song.artist) notification_lines.append(song.artist)
notification_text = '\n'.join(notification_lines) notification_text = "\n".join(notification_lines)
osascript_command = [ osascript_command = [
'display', "display",
'notification', "notification",
f'"{notification_text}"', f'"{notification_text}"',
'with', "with",
'title', "title",
f'"{song.title}"', f'"{song.title}"',
] ]
os.system( os.system(f"osascript -e '{' '.join(osascript_command)}'")
f"osascript -e '{' '.join(osascript_command)}'")
except Exception: except Exception:
logging.warning( logging.warning(
'Unable to display notification. Is a notification ' "Unable to display notification. Is a notification "
'daemon running?') "daemon running?"
)
def on_song_download_complete(song_id: int): def on_song_download_complete(song_id: int):
if (self.app_config.state.current_song if (
and self.app_config.state.current_song.id != song.id): self.app_config.state.current_song
and self.app_config.state.current_song.id != song.id
):
return return
if not self.app_config.state.playing: if not self.app_config.state.playing:
return return
@@ -1071,8 +1063,11 @@ class SublimeMusicApp(Gtk.Application):
# If streaming, also download the song, unless configured not to, # If streaming, also download the song, unless configured not to,
# or configured to always stream. # or configured to always stream.
if (stream and self.app_config.download_on_stream if (
and not self.app_config.always_stream): stream
and self.app_config.download_on_stream
and not self.app_config.always_stream
):
CacheManager.batch_download_songs( CacheManager.batch_download_songs(
[song.id], [song.id],
before_download=lambda: self.update_window(), before_download=lambda: self.update_window(),
@@ -1080,9 +1075,7 @@ class SublimeMusicApp(Gtk.Application):
) )
self.player.play_media( self.player.play_media(
uri, uri, 0 if reset else self.app_config.state.song_progress, song,
0 if reset else self.app_config.state.song_progress,
song,
) )
self.app_config.state.playing = True self.app_config.state.playing = True
self.update_window() self.update_window()
@@ -1096,17 +1089,16 @@ class SublimeMusicApp(Gtk.Application):
for i in range(self.app_config.prefetch_amount): for i in range(self.app_config.prefetch_amount):
prefetch_idx: int = song_idx + 1 + i prefetch_idx: int = song_idx + 1 + i
play_queue_len: int = len(self.app_config.state.play_queue) 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_idxs.append(
prefetch_idx % play_queue_len) # noqa: S001 prefetch_idx % play_queue_len
) # noqa: S001
CacheManager.batch_download_songs( 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), before_download=lambda: GLib.idle_add(self.update_window),
on_song_download_complete=lambda _: GLib.idle_add( on_song_download_complete=lambda _: GLib.idle_add(
self.update_window), self.update_window
),
) )
if old_play_queue: if old_play_queue:
@@ -1121,10 +1113,11 @@ class SublimeMusicApp(Gtk.Application):
self.save_play_queue() self.save_play_queue()
song_details_future = CacheManager.get_song_details( song_details_future = CacheManager.get_song_details(
self.app_config.state.play_queue[ self.app_config.state.play_queue[self.app_config.state.current_song_index]
self.app_config.state.current_song_index]) )
song_details_future.add_done_callback( 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): def save_play_queue(self):
if len(self.app_config.state.play_queue) == 0: if len(self.app_config.state.play_queue) == 0:
@@ -1133,8 +1126,7 @@ class SublimeMusicApp(Gtk.Application):
position = self.app_config.state.song_progress position = self.app_config.state.song_progress
self.last_play_queue_update = position or 0 self.last_play_queue_update = position or 0
if (self.app_config.server.sync_enabled if self.app_config.server.sync_enabled and self.app_config.state.current_song:
and self.app_config.state.current_song):
CacheManager.save_play_queue( CacheManager.save_play_queue(
play_queue=self.app_config.state.play_queue, play_queue=self.app_config.state.play_queue,
current=self.app_config.state.current_song.id, current=self.app_config.state.current_song.id,

View File

@@ -34,14 +34,17 @@ from fuzzywuzzy import fuzz
try: try:
import gi import gi
gi.require_version('NM', '1.0')
gi.require_version("NM", "1.0")
from gi.repository import NM from gi.repository import NM
networkmanager_imported = True networkmanager_imported = True
except Exception: except Exception:
# I really don't care what kind of exception it is, all that matters is the # I really don't care what kind of exception it is, all that matters is the
# import failed for some reason. # import failed for some reason.
logging.warning( 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 networkmanager_imported = False
from .config import AppConfiguration from .config import AppConfiguration
@@ -67,6 +70,7 @@ class Singleton(type):
Metaclass for :class:`CacheManager` so that it can be used like a Metaclass for :class:`CacheManager` so that it can be used like a
singleton. singleton.
""" """
def __getattr__(cls, name: str) -> Any: def __getattr__(cls, name: str) -> Any:
if not CacheManager._instance: if not CacheManager._instance:
return None return None
@@ -103,7 +107,7 @@ def similarity_ratio(query: str, string: str) -> int:
return fuzz.partial_ratio(query.lower(), string.lower()) return fuzz.partial_ratio(query.lower(), string.lower())
S = TypeVar('S') S = TypeVar("S")
class SearchResult: class SearchResult:
@@ -111,6 +115,7 @@ class SearchResult:
An object representing the aggregate results of a search which can include An object representing the aggregate results of a search which can include
both server and local results. both server and local results.
""" """
_artist: Set[ArtistID3] = set() _artist: Set[ArtistID3] = set()
_album: Set[AlbumID3] = set() _album: Set[AlbumID3] = set()
_song: Set[Child] = set() _song: Set[Child] = set()
@@ -124,21 +129,15 @@ class SearchResult:
if results is None: if results is None:
return return
member = f'_{result_type}' member = f"_{result_type}"
if getattr(self, member) is None: if getattr(self, member) is None:
setattr(self, member, set()) setattr(self, member, set())
setattr( setattr(
self, self, member, getattr(getattr(self, member, set()), "union")(set(results)),
member,
getattr(getattr(self, member, set()), 'union')(set(results)),
) )
def _to_result( def _to_result(self, it: Iterable[S], transform: Callable[[S], str],) -> List[S]:
self,
it: Iterable[S],
transform: Callable[[S], str],
) -> List[S]:
all_results = sorted( all_results = sorted(
((similarity_ratio(self.query, transform(x)), x) for x in it), ((similarity_ratio(self.query, transform(x)), x) for x in it),
key=lambda rx: rx[0], key=lambda rx: rx[0],
@@ -164,13 +163,13 @@ class SearchResult:
if self._album is None: if self._album is None:
return 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 @property
def song(self) -> Optional[List[Child]]: def song(self) -> Optional[List[Child]]:
if self._song is None: if self._song is None:
return 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 @property
def playlist(self) -> Optional[List[Playlist]]: def playlist(self) -> Optional[List[Playlist]]:
@@ -179,13 +178,14 @@ class SearchResult:
return self._to_result(self._playlist, lambda p: p.name) return self._to_result(self._playlist, lambda p: p.name)
T = TypeVar('T') T = TypeVar("T")
class CacheManager(metaclass=Singleton): class CacheManager(metaclass=Singleton):
""" """
Handles everything related to caching metadata and song files. Handles everything related to caching metadata and song files.
""" """
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50) executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50)
should_exit: bool = False should_exit: bool = False
@@ -205,8 +205,8 @@ class CacheManager(metaclass=Singleton):
on_cancel: Optional[Callable[[], None]] = None on_cancel: Optional[Callable[[], None]] = None
@staticmethod @staticmethod
def from_data(data: T) -> 'CacheManager.Result[T]': def from_data(data: T) -> "CacheManager.Result[T]":
result: 'CacheManager.Result[T]' = CacheManager.Result() result: "CacheManager.Result[T]" = CacheManager.Result()
result.data = data result.data = data
return result return result
@@ -216,8 +216,8 @@ class CacheManager(metaclass=Singleton):
before_download: Callable[[], Any] = None, before_download: Callable[[], Any] = None,
after_download: Callable[[T], Any] = None, after_download: Callable[[T], Any] = None,
on_cancel: Callable[[], Any] = None, on_cancel: Callable[[], Any] = None,
) -> 'CacheManager.Result[T]': ) -> "CacheManager.Result[T]":
result: 'CacheManager.Result[T]' = CacheManager.Result() result: "CacheManager.Result[T]" = CacheManager.Result()
def future_fn() -> T: def future_fn() -> T:
if before_download: if before_download:
@@ -229,7 +229,8 @@ class CacheManager(metaclass=Singleton):
if after_download is not None: if after_download is not None:
result.future.add_done_callback( 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 return result
@@ -240,8 +241,8 @@ class CacheManager(metaclass=Singleton):
return self.future.result() return self.future.result()
raise Exception( raise Exception(
'CacheManager.Result did not have either a data or future ' "CacheManager.Result did not have either a data or future " "member."
'member.') )
def add_done_callback(self, fn: Callable, *args): def add_done_callback(self, fn: Callable, *args):
if self.future is not None: if self.future is not None:
@@ -268,11 +269,11 @@ class CacheManager(metaclass=Singleton):
@staticmethod @staticmethod
def shutdown(): def shutdown():
logging.info('CacheManager shutdown start') logging.info("CacheManager shutdown start")
CacheManager.should_exit = True CacheManager.should_exit = True
CacheManager.executor.shutdown() CacheManager.executor.shutdown()
CacheManager._instance.save_cache_info() CacheManager._instance.save_cache_info()
logging.info('CacheManager shutdown complete') logging.info("CacheManager shutdown complete")
class CacheEncoder(json.JSONEncoder): class CacheEncoder(json.JSONEncoder):
def default(self, obj: Any) -> Optional[Union[int, List, Dict]]: 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, disable_cert_verify=self.app_config.server.disable_cert_verify,
) )
self.download_limiter_semaphore = threading.Semaphore( self.download_limiter_semaphore = threading.Semaphore(
self.app_config.concurrent_download_limit) self.app_config.concurrent_download_limit
)
self.load_cache_info() self.load_cache_info()
@@ -346,7 +348,7 @@ class CacheManager(metaclass=Singleton):
if not self.nmclient_initialized: if not self.nmclient_initialized:
# Only look at the active WiFi connections. # Only look at the active WiFi connections.
for ac in self.networkmanager_client.get_active_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 continue
devs = ac.get_devices() devs = ac.get_devices()
if len(devs) != 1: if len(devs) != 1:
@@ -359,71 +361,65 @@ class CacheManager(metaclass=Singleton):
return self._current_ssids return self._current_ssids
def load_cache_info(self): 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 = {} meta_json = {}
if cache_meta_file.exists(): if cache_meta_file.exists():
with open(cache_meta_file, 'r') as f: with open(cache_meta_file, "r") as f:
try: try:
meta_json = json.load(f) meta_json = json.load(f)
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
# Just continue with the default meta_json. # Just continue with the default meta_json.
logging.warning( logging.warning("Unable to load cache", stack_info=True)
'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: if cache_version < 1:
logging.info('Migrating cache to version 1.') logging.info("Migrating cache to version 1.")
cover_art_re = re.compile(r'(\d+)_(\d+)') cover_art_re = re.compile(r"(\d+)_(\d+)")
abs_path = self.calculate_abs_path('cover_art/') abs_path = self.calculate_abs_path("cover_art/")
abs_path.mkdir(parents=True, exist_ok=True) abs_path.mkdir(parents=True, exist_ok=True)
for cover_art_file in abs_path.iterdir(): for cover_art_file in abs_path.iterdir():
match = cover_art_re.match(cover_art_file.name) match = cover_art_re.match(cover_art_file.name)
if match: if match:
art_id, dimensions = map(int, match.groups()) art_id, dimensions = map(int, match.groups())
if dimensions == 1000: if dimensions == 1000:
no_dimens = cover_art_file.parent.joinpath( no_dimens = cover_art_file.parent.joinpath("{art_id}")
'{art_id}') logging.debug(f"Moving {cover_art_file} to {no_dimens}")
logging.debug(
f'Moving {cover_art_file} to {no_dimens}')
shutil.move(cover_art_file, no_dimens) shutil.move(cover_art_file, no_dimens)
else: else:
logging.debug(f'Deleting {cover_art_file}') logging.debug(f"Deleting {cover_art_file}")
cover_art_file.unlink() cover_art_file.unlink()
self.cache['version'] = 1 self.cache["version"] = 1
cache_configs = [ cache_configs = [
# Playlists # Playlists
('playlists', Playlist, list), ("playlists", Playlist, list),
('playlist_details', PlaylistWithSongs, dict), ("playlist_details", PlaylistWithSongs, dict),
('genres', Genre, list), ("genres", Genre, list),
('song_details', Child, dict), ("song_details", Child, dict),
# Non-ID3 caches # Non-ID3 caches
('music_directories', Directory, dict), ("music_directories", Directory, dict),
('indexes', Artist, list), ("indexes", Artist, list),
# ID3 caches # ID3 caches
('albums', AlbumWithSongsID3, 'dict-list'), ("albums", AlbumWithSongsID3, "dict-list"),
('album_details', AlbumWithSongsID3, dict), ("album_details", AlbumWithSongsID3, dict),
('artists', ArtistID3, list), ("artists", ArtistID3, list),
('artist_details', ArtistWithAlbumsID3, dict), ("artist_details", ArtistWithAlbumsID3, dict),
('artist_infos', ArtistInfo2, dict), ("artist_infos", ArtistInfo2, dict),
] ]
for name, type_name, default in cache_configs: for name, type_name, default in cache_configs:
if default == list: if default == list:
self.cache[name] = [ self.cache[name] = [
type_name.from_json(x) type_name.from_json(x) for x in meta_json.get(name) or []
for x in meta_json.get(name) or []
] ]
elif default == dict: elif default == dict:
self.cache[name] = { self.cache[name] = {
id: type_name.from_json(x) id: type_name.from_json(x)
for id, x in (meta_json.get(name) or {}).items() for id, x in (meta_json.get(name) or {}).items()
} }
elif default == 'dict-list': elif default == "dict-list":
self.cache[name] = { self.cache[name] = {
n: [type_name.from_json(x) for x in xs] n: [type_name.from_json(x) for x in xs]
for n, xs in (meta_json.get(name) or {}).items() for n, xs in (meta_json.get(name) or {}).items()
@@ -432,36 +428,33 @@ class CacheManager(metaclass=Singleton):
def save_cache_info(self): def save_cache_info(self):
os.makedirs(self.app_config.cache_location, exist_ok=True) 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) os.makedirs(os.path.dirname(cache_meta_file), exist_ok=True)
with open(cache_meta_file, 'w+') as f, self.cache_lock: with open(cache_meta_file, "w+") as f, self.cache_lock:
f.write( f.write(json.dumps(self.cache, indent=2, cls=CacheManager.CacheEncoder))
json.dumps(
self.cache, indent=2, cls=CacheManager.CacheEncoder))
def save_file(self, absolute_path: Path, data: bytes): def save_file(self, absolute_path: Path, data: bytes):
# Make the necessary directories and write to file. # Make the necessary directories and write to file.
os.makedirs(absolute_path.parent, exist_ok=True) 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) f.write(data)
def calculate_abs_path(self, *relative_paths) -> Path: def calculate_abs_path(self, *relative_paths) -> Path:
assert self.app_config.server is not None assert self.app_config.server is not None
return Path(self.app_config.cache_location).joinpath( 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: def calculate_download_path(self, *relative_paths) -> Path:
""" """
Determine where to temporarily put the file as it is downloading. Determine where to temporarily put the file as it is downloading.
""" """
assert self.app_config.server is not None assert self.app_config.server is not None
xdg_cache_home = ( xdg_cache_home = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser(
os.environ.get('XDG_CACHE_HOME') "~/.cache"
or os.path.expanduser('~/.cache')) )
return Path(xdg_cache_home).joinpath( return Path(xdg_cache_home).joinpath(
'sublime-music', "sublime-music", self.app_config.server.strhash(), *relative_paths,
self.app_config.server.strhash(),
*relative_paths,
) )
def return_cached_or_download( def return_cached_or_download(
@@ -471,7 +464,7 @@ class CacheManager(metaclass=Singleton):
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
allow_download: bool = True, allow_download: bool = True,
) -> 'CacheManager.Result[str]': ) -> "CacheManager.Result[str]":
abs_path = self.calculate_abs_path(relative_path) abs_path = self.calculate_abs_path(relative_path)
abs_path_str = str(abs_path) abs_path_str = str(abs_path)
download_path = self.calculate_download_path(relative_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) return CacheManager.Result.from_data(abs_path_str)
if not allow_download: if not allow_download:
return CacheManager.Result.from_data('') return CacheManager.Result.from_data("")
def do_download() -> str: def do_download() -> str:
resource_downloading = False resource_downloading = False
@@ -491,14 +484,14 @@ class CacheManager(metaclass=Singleton):
self.current_downloads.add(abs_path_str) self.current_downloads.add(abs_path_str)
if resource_downloading: 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 # The resource is already being downloaded. Busy loop until
# it has completed. Then, just return the path to the # it has completed. Then, just return the path to the
# resource. # resource.
while abs_path_str in self.current_downloads: while abs_path_str in self.current_downloads:
sleep(0.2) sleep(0.2)
else: 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) os.makedirs(download_path.parent, exist_ok=True)
try: try:
@@ -512,7 +505,7 @@ class CacheManager(metaclass=Singleton):
if download_path.exists(): if download_path.exists():
shutil.move(str(download_path), abs_path) 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 return abs_path_str
def after_download(path: str): def after_download(path: str):
@@ -531,7 +524,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.executor.submit(fn, *args) return CacheManager.executor.submit(fn, *args)
def delete_cached_cover_art(self, id: int): 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) abs_path = self.calculate_abs_path(relative_path)
@@ -542,14 +535,13 @@ class CacheManager(metaclass=Singleton):
self, self,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[List[Playlist]]': ) -> "CacheManager.Result[List[Playlist]]":
if self.cache.get('playlists') and not force: if self.cache.get("playlists") and not force:
return CacheManager.Result.from_data( return CacheManager.Result.from_data(self.cache["playlists"] or [])
self.cache['playlists'] or [])
def after_download(playlists: List[Playlist]): def after_download(playlists: List[Playlist]):
with self.cache_lock: with self.cache_lock:
self.cache['playlists'] = playlists self.cache["playlists"] = playlists
self.save_cache_info() self.save_cache_info()
return CacheManager.Result.from_server( return CacheManager.Result.from_server(
@@ -559,11 +551,11 @@ class CacheManager(metaclass=Singleton):
) )
def invalidate_playlists_cache(self): def invalidate_playlists_cache(self):
if not self.cache.get('playlists'): if not self.cache.get("playlists"):
return return
with self.cache_lock: with self.cache_lock:
self.cache['playlists'] = [] self.cache["playlists"] = []
self.save_cache_info() self.save_cache_info()
def get_playlist( def get_playlist(
@@ -571,19 +563,18 @@ class CacheManager(metaclass=Singleton):
playlist_id: int, playlist_id: int,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[PlaylistWithSongs]': ) -> "CacheManager.Result[PlaylistWithSongs]":
playlist_details = self.cache.get('playlist_details', {}) playlist_details = self.cache.get("playlist_details", {})
if playlist_id in playlist_details and not force: if playlist_id in playlist_details and not force:
return CacheManager.Result.from_data( return CacheManager.Result.from_data(playlist_details[playlist_id])
playlist_details[playlist_id])
def after_download(playlist: PlaylistWithSongs): def after_download(playlist: PlaylistWithSongs):
with self.cache_lock: 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. # Playlists have the song details, so save those too.
for song in (playlist.entry or []): for song in playlist.entry or []:
self.cache['song_details'][song.id] = song self.cache["song_details"][song.id] = song
self.save_cache_info() self.save_cache_info()
@@ -608,7 +599,7 @@ class CacheManager(metaclass=Singleton):
def do_update_playlist(): def do_update_playlist():
self.server.update_playlist(playlist_id, *args, **kwargs) self.server.update_playlist(playlist_id, *args, **kwargs)
with self.cache_lock: 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) return CacheManager.create_future(do_update_playlist)
@@ -616,8 +607,8 @@ class CacheManager(metaclass=Singleton):
self, self,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[List[ArtistID3]]': ) -> "CacheManager.Result[List[ArtistID3]]":
cache_name = 'artists' cache_name = "artists"
if self.cache.get(cache_name) and not force: if self.cache.get(cache_name) and not force:
return CacheManager.Result.from_data(self.cache[cache_name]) return CacheManager.Result.from_data(self.cache[cache_name])
@@ -644,12 +635,11 @@ class CacheManager(metaclass=Singleton):
artist_id: int, artist_id: int,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[ArtistWithAlbumsID3]': ) -> "CacheManager.Result[ArtistWithAlbumsID3]":
cache_name = 'artist_details' cache_name = "artist_details"
if artist_id in self.cache.get(cache_name, {}) and not force: if artist_id in self.cache.get(cache_name, {}) and not force:
return CacheManager.Result.from_data( return CacheManager.Result.from_data(self.cache[cache_name][artist_id])
self.cache[cache_name][artist_id])
def after_download(artist: ArtistWithAlbumsID3): def after_download(artist: ArtistWithAlbumsID3):
with self.cache_lock: with self.cache_lock:
@@ -666,8 +656,8 @@ class CacheManager(metaclass=Singleton):
self, self,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[List[Artist]]': ) -> "CacheManager.Result[List[Artist]]":
cache_name = 'indexes' cache_name = "indexes"
if self.cache.get(cache_name) and not force: if self.cache.get(cache_name) and not force:
return CacheManager.Result.from_data(self.cache[cache_name]) return CacheManager.Result.from_data(self.cache[cache_name])
@@ -694,12 +684,11 @@ class CacheManager(metaclass=Singleton):
id: int, id: int,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[Directory]': ) -> "CacheManager.Result[Directory]":
cache_name = 'music_directories' cache_name = "music_directories"
if id in self.cache.get(cache_name, {}) and not force: if id in self.cache.get(cache_name, {}) and not force:
return CacheManager.Result.from_data( return CacheManager.Result.from_data(self.cache[cache_name][id])
self.cache[cache_name][id])
def after_download(directory: Directory): def after_download(directory: Directory):
with self.cache_lock: with self.cache_lock:
@@ -717,12 +706,11 @@ class CacheManager(metaclass=Singleton):
artist_id: int, artist_id: int,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[ArtistInfo2]': ) -> "CacheManager.Result[ArtistInfo2]":
cache_name = 'artist_infos' cache_name = "artist_infos"
if artist_id in self.cache.get(cache_name, {}) and not force: if artist_id in self.cache.get(cache_name, {}) and not force:
return CacheManager.Result.from_data( return CacheManager.Result.from_data(self.cache[cache_name][artist_id])
self.cache[cache_name][artist_id])
def after_download(artist_info: ArtistInfo2): def after_download(artist_info: ArtistInfo2):
if not artist_info: if not artist_info:
@@ -733,8 +721,7 @@ class CacheManager(metaclass=Singleton):
self.save_cache_info() self.save_cache_info()
return CacheManager.Result.from_server( return CacheManager.Result.from_server(
lambda: lambda: (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()),
(self.server.get_artist_info2(id=artist_id) or ArtistInfo2()),
before_download=before_download, before_download=before_download,
after_download=after_download, after_download=after_download,
) )
@@ -744,48 +731,53 @@ class CacheManager(metaclass=Singleton):
artist: Union[Artist, ArtistID3], artist: Union[Artist, ArtistID3],
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[str]': ) -> "CacheManager.Result[str]":
def do_get_artist_artwork( def do_get_artist_artwork(
artist_info: ArtistInfo2) -> 'CacheManager.Result[str]': artist_info: ArtistInfo2,
lastfm_url = ''.join(artist_info.largeImageUrl or []) ) -> "CacheManager.Result[str]":
lastfm_url = "".join(artist_info.largeImageUrl or [])
is_placeholder = lastfm_url == '' is_placeholder = lastfm_url == ""
is_placeholder |= lastfm_url.endswith( is_placeholder |= lastfm_url.endswith(
'2a96cbd8b46e442fc41c2b86b821562f.png') "2a96cbd8b46e442fc41c2b86b821562f.png"
)
is_placeholder |= lastfm_url.endswith( 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 # If it is the placeholder LastFM image, try and use the cover
# art filename given by the server. # art filename given by the server.
if is_placeholder: if is_placeholder:
if isinstance(artist, (ArtistWithAlbumsID3, ArtistID3)): if isinstance(artist, (ArtistWithAlbumsID3, ArtistID3)):
if artist.coverArt: 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( return CacheManager.get_cover_art_filename(
artist.coverArt) artist.album[0].coverArt
elif (isinstance(artist, ArtistWithAlbumsID3) )
and artist.album and len(artist.album) > 0):
return CacheManager.get_cover_art_filename(
artist.album[0].coverArt)
elif (isinstance(artist, Directory) elif isinstance(artist, Directory) and len(artist.child) > 0:
and len(artist.child) > 0):
# Retrieve the first album's cover art # Retrieve the first album's cover art
return CacheManager.get_cover_art_filename( return CacheManager.get_cover_art_filename(
artist.child[0].coverArt) artist.child[0].coverArt
)
if lastfm_url == '': if lastfm_url == "":
return CacheManager.Result.from_data('') 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( return self.return_cached_or_download(
f'cover_art/artist.{url_hash}', f"cover_art/artist.{url_hash}",
lambda: requests.get(lastfm_url).content, lambda: requests.get(lastfm_url).content,
before_download=before_download, before_download=before_download,
force=force, force=force,
) )
def download_fn( def download_fn(artist_info: CacheManager.Result[ArtistInfo2]) -> str:
artist_info: CacheManager.Result[ArtistInfo2]) -> str:
# In this case, artist_info is a future, so we have to wait for # In this case, artist_info is a future, so we have to wait for
# its result before calculating. Then, immediately unwrap the # its result before calculating. Then, immediately unwrap the
# result() because we are already within a future. # result() because we are already within a future.
@@ -794,8 +786,7 @@ class CacheManager(metaclass=Singleton):
artist_info = CacheManager.get_artist_info(artist.id) artist_info = CacheManager.get_artist_info(artist.id)
if artist_info.is_future: if artist_info.is_future:
return CacheManager.Result.from_server( return CacheManager.Result.from_server(
lambda: download_fn(artist_info), lambda: download_fn(artist_info), before_download=before_download,
before_download=before_download,
) )
else: else:
return do_get_artist_artwork(artist_info.result()) return do_get_artist_artwork(artist_info.result())
@@ -807,27 +798,22 @@ class CacheManager(metaclass=Singleton):
force: bool = False, force: bool = False,
# Look at documentation for get_album_list in server.py: # Look at documentation for get_album_list in server.py:
**params, **params,
) -> 'CacheManager.Result[List[AlbumID3]]': ) -> "CacheManager.Result[List[AlbumID3]]":
cache_name = 'albums' cache_name = "albums"
if (len(self.cache.get(cache_name, {}).get(type_, [])) > 0 if len(self.cache.get(cache_name, {}).get(type_, [])) > 0 and not force:
and not force): return CacheManager.Result.from_data(self.cache[cache_name][type_])
return CacheManager.Result.from_data(
self.cache[cache_name][type_])
def do_get_album_list() -> List[AlbumID3]: def do_get_album_list() -> List[AlbumID3]:
def get_page( def get_page(offset: int, page_size: int = 500,) -> List[AlbumID3]:
offset: int, return (
page_size: int = 500, self.server.get_album_list2(
) -> List[AlbumID3]: type_, size=page_size, offset=offset, **params,
return self.server.get_album_list2( ).album
type_, or []
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 offset = 0
next_page = get_page(offset, page_size=page_size) next_page = get_page(offset, page_size=page_size)
@@ -859,20 +845,19 @@ class CacheManager(metaclass=Singleton):
album_id: int, album_id: int,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[AlbumWithSongsID3]': ) -> "CacheManager.Result[AlbumWithSongsID3]":
cache_name = 'album_details' cache_name = "album_details"
if album_id in self.cache.get(cache_name, {}) and not force: if album_id in self.cache.get(cache_name, {}) and not force:
return CacheManager.Result.from_data( return CacheManager.Result.from_data(self.cache[cache_name][album_id])
self.cache[cache_name][album_id])
def after_download(album: AlbumWithSongsID3): def after_download(album: AlbumWithSongsID3):
with self.cache_lock: with self.cache_lock:
self.cache[cache_name][album_id] = album self.cache[cache_name][album_id] = album
# Albums have the song details as well, so save those too. # Albums have the song details as well, so save those too.
for song in album.get('song', []): for song in album.get("song", []):
self.cache['song_details'][song.id] = song self.cache["song_details"][song.id] = song
self.save_cache_info() self.save_cache_info()
return CacheManager.Result.from_server( return CacheManager.Result.from_server(
@@ -882,9 +867,7 @@ class CacheManager(metaclass=Singleton):
) )
def batch_delete_cached_songs( def batch_delete_cached_songs(
self, self, song_ids: List[int], on_song_delete: Callable[[], None],
song_ids: List[int],
on_song_delete: Callable[[], None],
) -> Future: ) -> Future:
def do_delete_cached_songs(): def do_delete_cached_songs():
# Do the actual download. # Do the actual download.
@@ -946,13 +929,14 @@ class CacheManager(metaclass=Singleton):
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
allow_download: bool = True, allow_download: bool = True,
) -> 'CacheManager.Result[str]': ) -> "CacheManager.Result[str]":
if id is None: 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( 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( return self.return_cached_or_download(
f'cover_art/{id}', f"cover_art/{id}",
lambda: self.server.get_cover_art(id), lambda: self.server.get_cover_art(id),
before_download=before_download, before_download=before_download,
force=force, force=force,
@@ -964,11 +948,10 @@ class CacheManager(metaclass=Singleton):
song_id: int, song_id: int,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[Child]': ) -> "CacheManager.Result[Child]":
cache_name = 'song_details' cache_name = "song_details"
if self.cache[cache_name].get(song_id) and not force: if self.cache[cache_name].get(song_id) and not force:
return CacheManager.Result.from_data( return CacheManager.Result.from_data(self.cache[cache_name][song_id])
self.cache[cache_name][song_id])
def after_download(song_details: Child): def after_download(song_details: Child):
with self.cache_lock: with self.cache_lock:
@@ -985,13 +968,11 @@ class CacheManager(metaclass=Singleton):
return CacheManager.create_future(self.server.get_play_queue) return CacheManager.create_future(self.server.get_play_queue)
def save_play_queue( def save_play_queue(
self, self, play_queue: List[str], current: str, position: float,
play_queue: List[str],
current: str,
position: float,
): ):
CacheManager.create_future( 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 scrobble(self, song_id: int) -> Future:
def do_scrobble(): def do_scrobble():
@@ -1000,10 +981,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.create_future(do_scrobble) return CacheManager.create_future(do_scrobble)
def get_song_filename_or_stream( def get_song_filename_or_stream(
self, self, song: Child, format: str = None, force_stream: bool = False,
song: Child,
format: str = None,
force_stream: bool = False,
) -> Tuple[str, bool]: ) -> Tuple[str, bool]:
abs_path = self.calculate_abs_path(song.path) abs_path = self.calculate_abs_path(song.path)
if abs_path.exists() and not force_stream: if abs_path.exists() and not force_stream:
@@ -1014,13 +992,13 @@ class CacheManager(metaclass=Singleton):
self, self,
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
force: bool = False, force: bool = False,
) -> 'CacheManager.Result[List[Genre]]': ) -> "CacheManager.Result[List[Genre]]":
if self.cache.get('genres') and not force: if self.cache.get("genres") and not force:
return CacheManager.Result.from_data(self.cache['genres']) return CacheManager.Result.from_data(self.cache["genres"])
def after_download(genres: List[Genre]): def after_download(genres: List[Genre]):
with self.cache_lock: with self.cache_lock:
self.cache['genres'] = genres self.cache["genres"] = genres
self.save_cache_info() self.save_cache_info()
return CacheManager.Result.from_server( return CacheManager.Result.from_server(
@@ -1034,9 +1012,9 @@ class CacheManager(metaclass=Singleton):
query: str, query: str,
search_callback: Callable[[SearchResult, bool], None], search_callback: Callable[[SearchResult, bool], None],
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
) -> 'CacheManager.Result': ) -> "CacheManager.Result":
if query == '': if query == "":
search_callback(SearchResult(''), True) search_callback(SearchResult(""), True)
return CacheManager.Result.from_data(None) return CacheManager.Result.from_data(None)
before_download() before_download()
@@ -1058,11 +1036,11 @@ class CacheManager(metaclass=Singleton):
# Local Results # Local Results
search_result = SearchResult(query) search_result = SearchResult(query)
search_result.add_results( search_result.add_results(
'album', itertools.chain(*self.cache['albums'].values())) "album", itertools.chain(*self.cache["albums"].values())
search_result.add_results('artist', self.cache['artists']) )
search_result.add_results( search_result.add_results("artist", self.cache["artists"])
'song', self.cache['song_details'].values()) search_result.add_results("song", self.cache["song_details"].values())
search_result.add_results('playlist', self.cache['playlists']) search_result.add_results("playlist", self.cache["playlists"])
search_callback(search_result, False) search_callback(search_result, False)
# Wait longer to see if the user types anything else so we # 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 # SearchResult. If it fails, that's fine, we will use the
# finally to always return a final SearchResult to the UI. # finally to always return a final SearchResult to the UI.
server_result = search_fn(query) server_result = search_fn(query)
search_result.add_results('album', server_result.album) search_result.add_results("album", server_result.album)
search_result.add_results('artist', server_result.artist) search_result.add_results("artist", server_result.artist)
search_result.add_results('song', server_result.song) search_result.add_results("song", server_result.song)
except Exception: except Exception:
# We really don't care about what the exception was (could # We really don't care about what the exception was (could
# be connection error, could be invalid JSON, etc.) because # be connection error, could be invalid JSON, etc.) because
@@ -1095,8 +1073,7 @@ class CacheManager(metaclass=Singleton):
nonlocal cancelled nonlocal cancelled
cancelled = True cancelled = True
return CacheManager.Result.from_server( return CacheManager.Result.from_server(do_search, on_cancel=on_cancel)
do_search, on_cancel=on_cancel)
def get_cached_status(self, song: Child) -> SongCacheStatus: def get_cached_status(self, song: Child) -> SongCacheStatus:
cache_path = self.calculate_abs_path(song.path) cache_path = self.calculate_abs_path(song.path)
@@ -1113,10 +1090,9 @@ class CacheManager(metaclass=Singleton):
_instance: Optional[__CacheManagerInternal] = None _instance: Optional[__CacheManagerInternal] = None
def __init__(self): def __init__(self):
raise Exception('Do not instantiate the CacheManager.') raise Exception("Do not instantiate the CacheManager.")
@staticmethod @staticmethod
def reset(app_config: AppConfiguration): def reset(app_config: AppConfiguration):
CacheManager._instance = CacheManager.__CacheManagerInternal( CacheManager._instance = CacheManager.__CacheManagerInternal(app_config)
app_config)
similarity_ratio.cache_clear() similarity_ratio.cache_clear()

View File

@@ -26,26 +26,26 @@ class ReplayGainType(Enum):
ALBUM = 2 ALBUM = 2
def as_string(self) -> str: def as_string(self) -> str:
return ['no', 'track', 'album'][self.value] return ["no", "track", "album"][self.value]
@staticmethod @staticmethod
def from_string(replay_gain_type: str) -> 'ReplayGainType': def from_string(replay_gain_type: str) -> "ReplayGainType":
return { return {
'no': ReplayGainType.NO, "no": ReplayGainType.NO,
'disabled': ReplayGainType.NO, "disabled": ReplayGainType.NO,
'track': ReplayGainType.TRACK, "track": ReplayGainType.TRACK,
'album': ReplayGainType.ALBUM, "album": ReplayGainType.ALBUM,
}[replay_gain_type.lower()] }[replay_gain_type.lower()]
@dataclass(unsafe_hash=True) @dataclass(unsafe_hash=True)
class ServerConfiguration: class ServerConfiguration:
name: str = 'Default' name: str = "Default"
server_address: str = 'http://yourhost' server_address: str = "http://yourhost"
local_network_address: str = '' local_network_address: str = ""
local_network_ssid: str = '' local_network_ssid: str = ""
username: str = '' username: str = ""
password: str = '' password: str = ""
sync_enabled: bool = True sync_enabled: bool = True
disable_cert_verify: bool = False disable_cert_verify: bool = False
version: int = 0 version: int = 0
@@ -70,14 +70,14 @@ class ServerConfiguration:
'6df23dc03f9b54cc38a0fc1483df6e21' '6df23dc03f9b54cc38a0fc1483df6e21'
""" """
server_info = self.name + self.server_address + self.username 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 @dataclass
class AppConfiguration: class AppConfiguration:
servers: List[ServerConfiguration] = field(default_factory=list) servers: List[ServerConfiguration] = field(default_factory=list)
current_server_index: int = -1 current_server_index: int = -1
cache_location: str = '' cache_location: str = ""
max_cache_size_mb: int = -1 # -1 means unlimited max_cache_size_mb: int = -1 # -1 means unlimited
always_stream: bool = False # always stream instead of downloading songs always_stream: bool = False # always stream instead of downloading songs
download_on_stream: bool = True # also download when streaming a song download_on_stream: bool = True # also download when streaming a song
@@ -91,10 +91,10 @@ class AppConfiguration:
filename: Optional[Path] = None filename: Optional[Path] = None
@staticmethod @staticmethod
def load_from_file(filename: Path) -> 'AppConfiguration': def load_from_file(filename: Path) -> "AppConfiguration":
args = {} args = {}
if filename.exists(): if filename.exists():
with open(filename, 'r') as f: with open(filename, "r") as f:
field_names = {f.name for f in fields(AppConfiguration)} field_names = {f.name for f in fields(AppConfiguration)}
args = yaml.load(f, Loader=yaml.CLoader).items() args = yaml.load(f, Loader=yaml.CLoader).items()
args = dict(filter(lambda kv: kv[0] in field_names, args)) args = dict(filter(lambda kv: kv[0] in field_names, args))
@@ -107,13 +107,12 @@ class AppConfiguration:
def __post_init__(self): def __post_init__(self):
# Default the cache_location to ~/.local/share/sublime-music # Default the cache_location to ~/.local/share/sublime-music
if not self.cache_location: if not self.cache_location:
path = Path(os.environ.get('XDG_DATA_HOME') or '~/.local/share') path = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
path = path.expanduser().joinpath('sublime-music').resolve() path = path.expanduser().joinpath("sublime-music").resolve()
self.cache_location = path.as_posix() self.cache_location = path.as_posix()
# Deserialize the YAML into the ServerConfiguration object. # Deserialize the YAML into the ServerConfiguration object.
if (len(self.servers) > 0 if len(self.servers) > 0 and type(self.servers[0]) != ServerConfiguration:
and type(self.servers[0]) != ServerConfiguration):
self.servers = [ServerConfiguration(**sc) for sc in self.servers] self.servers = [ServerConfiguration(**sc) for sc in self.servers]
self._state = None self._state = None
@@ -152,17 +151,17 @@ class AppConfiguration:
self._current_server_hash = self.server.strhash() self._current_server_hash = self.server.strhash()
if self.state_file_location.exists(): if self.state_file_location.exists():
try: 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)) self._state = UIState(**pickle.load(f))
except Exception: except Exception:
logging.warning( logging.warning(f"Couldn't load state from {self.state_file_location}")
f"Couldn't load state from {self.state_file_location}")
# Just ignore any errors, it is only UI state. # Just ignore any errors, it is only UI state.
self._state = UIState() self._state = UIState()
# Do the import in the function to avoid circular imports. # Do the import in the function to avoid circular imports.
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.adapters import AdapterManager from sublime.adapters import AdapterManager
CacheManager.reset(self) CacheManager.reset(self)
AdapterManager.reset(self) AdapterManager.reset(self)
@@ -171,18 +170,18 @@ class AppConfiguration:
assert self.server is not None assert self.server is not None
server_hash = self.server.strhash() server_hash = self.server.strhash()
state_file_location = Path( state_file_location = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
os.environ.get('XDG_DATA_HOME') or '~/.local/share')
return state_file_location.expanduser().joinpath( return state_file_location.expanduser().joinpath(
'sublime-music', server_hash, 'state.pickle') "sublime-music", server_hash, "state.pickle"
)
def save(self): def save(self):
# Save the config as YAML. # Save the config as YAML.
self.filename.parent.mkdir(parents=True, exist_ok=True) 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))) f.write(yaml.dump(asdict(self)))
# Save the state for the current server. # Save the state for the current server.
self.state_file_location.parent.mkdir(parents=True, exist_ok=True) 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) pickle.dump(asdict(self.state), f)

View File

@@ -1,3 +1,3 @@
from .manager import DBusManager, dbus_propagate from .manager import dbus_propagate, DBusManager
__all__ = ('DBusManager', 'dbus_propagate') __all__ = ("dbus_propagate", "DBusManager")

View File

@@ -8,6 +8,7 @@ from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple
from deepdiff import DeepDiff from deepdiff import DeepDiff
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
from sublime.adapters import AdapterManager
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.players import Player from sublime.players import Player
@@ -15,9 +16,8 @@ from sublime.ui.state import RepeatType
def dbus_propagate(param_self: Any = None) -> Callable: 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: def decorator(function: Callable) -> Callable:
@functools.wraps(function) @functools.wraps(function)
def wrapper(*args): def wrapper(*args):
@@ -38,7 +38,8 @@ class DBusManager:
def __init__( def __init__(
self, self,
connection: Gio.DBusConnection, connection: Gio.DBusConnection,
do_on_method_call: Callable[[ do_on_method_call: Callable[
[
Gio.DBusConnection, Gio.DBusConnection,
str, str,
str, str,
@@ -46,11 +47,13 @@ class DBusManager:
str, str,
GLib.Variant, GLib.Variant,
Gio.DBusMethodInvocation, Gio.DBusMethodInvocation,
], None], ],
None,
],
on_set_property: Callable[ on_set_property: Callable[
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None], [Gio.DBusConnection, str, str, str, str, GLib.Variant], None
get_config_and_player: Callable[[], Tuple[AppConfiguration, ],
Optional[Player]]], get_config_and_player: Callable[[], Tuple[AppConfiguration, Optional[Player]]],
): ):
self.get_config_and_player = get_config_and_player self.get_config_and_player = get_config_and_player
self.do_on_method_call = do_on_method_call self.do_on_method_call = do_on_method_call
@@ -59,21 +62,20 @@ class DBusManager:
def dbus_name_acquired(connection: Gio.DBusConnection, name: str): def dbus_name_acquired(connection: Gio.DBusConnection, name: str):
specs = [ specs = [
'org.mpris.MediaPlayer2.xml', "org.mpris.MediaPlayer2.xml",
'org.mpris.MediaPlayer2.Player.xml', "org.mpris.MediaPlayer2.Player.xml",
'org.mpris.MediaPlayer2.Playlists.xml', "org.mpris.MediaPlayer2.Playlists.xml",
'org.mpris.MediaPlayer2.TrackList.xml', "org.mpris.MediaPlayer2.TrackList.xml",
] ]
for spec in specs: for spec in specs:
spec_path = os.path.join( spec_path = os.path.join(
os.path.dirname(__file__), os.path.dirname(__file__), f"mpris_specs/{spec}",
f'mpris_specs/{spec}',
) )
with open(spec_path) as f: with open(spec_path) as f:
node_info = Gio.DBusNodeInfo.new_for_xml(f.read()) node_info = Gio.DBusNodeInfo.new_for_xml(f.read())
connection.register_object( connection.register_object(
'/org/mpris/MediaPlayer2', "/org/mpris/MediaPlayer2",
node_info.interfaces[0], node_info.interfaces[0],
self.on_method_call, self.on_method_call,
self.on_get_property, self.on_get_property,
@@ -86,7 +88,7 @@ class DBusManager:
self.bus_number = Gio.bus_own_name_on_connection( self.bus_number = Gio.bus_own_name_on_connection(
connection, connection,
'org.mpris.MediaPlayer2.sublimemusic', "org.mpris.MediaPlayer2.sublimemusic",
Gio.BusNameOwnerFlags.NONE, Gio.BusNameOwnerFlags.NONE,
dbus_name_acquired, dbus_name_acquired,
dbus_name_lost, dbus_name_lost,
@@ -120,31 +122,23 @@ class DBusManager:
return return
# TODO (#127): I don't even know if this works. # TODO (#127): I don't even know if this works.
if interface == 'org.freedesktop.DBus.Properties': if interface == "org.freedesktop.DBus.Properties":
if method == 'Get': if method == "Get":
invocation.return_value( invocation.return_value(
self.on_get_property( self.on_get_property(connection, sender, path, interface, *params)
connection, sender, path, interface, *params)) )
elif method == 'Set': elif method == "Set":
self.on_set_property( self.on_set_property(connection, sender, path, interface, *params)
connection, sender, path, interface, *params) elif method == "GetAll":
elif method == 'GetAll':
all_properties = { all_properties = {
k: DBusManager.to_variant(v) k: DBusManager.to_variant(v)
for k, v in self.property_dict()[interface].items() for k, v in self.property_dict()[interface].items()
} }
invocation.return_value( invocation.return_value(GLib.Variant("(a{sv})", (all_properties,)))
GLib.Variant('(a{sv})', (all_properties, )))
return return
self.do_on_method_call( self.do_on_method_call(
connection, connection, sender, path, interface, method, params, invocation,
sender,
path,
interface,
method,
params,
invocation,
) )
@staticmethod @staticmethod
@@ -160,18 +154,12 @@ class DBusManager:
if type(value) == dict: if type(value) == dict:
return GLib.Variant( return GLib.Variant(
'a{sv}', "a{sv}", {k: DBusManager.to_variant(v) for k, v in value.items()},
{k: DBusManager.to_variant(v)
for k, v in value.items()},
) )
variant_type = { variant_type = {list: "as", str: "s", int: "i", float: "d", bool: "b"}.get(
list: 'as', type(value)
str: 's', )
int: 'i',
float: 'd',
bool: 'b',
}.get(type(value))
if not variant_type: if not variant_type:
return value return value
return GLib.Variant(variant_type, value) return GLib.Variant(variant_type, value)
@@ -183,113 +171,97 @@ class DBusManager:
state = config.state state = config.state
has_current_song = state.current_song is not None has_current_song = state.current_song is not None
has_next_song = False has_next_song = False
if state.repeat_type in (RepeatType.REPEAT_QUEUE, if state.repeat_type in (RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG):
RepeatType.REPEAT_SONG):
has_next_song = True has_next_song = True
elif has_current_song: elif has_current_song:
has_next_song = ( has_next_song = state.current_song_index < len(state.play_queue) - 1
state.current_song_index < len(state.play_queue) - 1)
if state.active_playlist_id is None: if state.active_playlist_id is None:
active_playlist = (False, GLib.Variant('(oss)', ('/', '', ''))) active_playlist = (False, GLib.Variant("(oss)", ("/", "", "")))
else: else:
playlist_result = CacheManager.get_playlist( playlist_result = AdapterManager.get_playlist_details(
state.active_playlist_id) state.active_playlist_id
)
if playlist_result.is_future: if playlist_result.data_is_available:
# If we have to wait for the playlist result, just return
# no playlist.
active_playlist = (False, GLib.Variant('(oss)', ('/', '', '')))
else:
playlist = playlist_result.result() playlist = playlist_result.result()
active_playlist = ( active_playlist = (
True, True,
GLib.Variant( GLib.Variant(
'(oss)', "(oss)",
( (
'/playlist/' + playlist.id, "/playlist/" + playlist.id,
playlist.name, playlist.name,
CacheManager.get_cover_art_url(playlist.coverArt), CacheManager.get_cover_art_url(playlist.cover_art),
), ),
), ),
) )
get_playlists_result = CacheManager.get_playlists()
if get_playlists_result.is_future:
playlist_count = 0
else: else:
# If we have to wait for the playlist result, just return
# no playlist.
active_playlist = (False, GLib.Variant("(oss)", ("/", "", "")))
get_playlists_result = AdapterManager.get_playlists()
if get_playlists_result.data_is_available:
playlist_count = len(get_playlists_result.result()) playlist_count = len(get_playlists_result.result())
else:
playlist_count = 0
return { return {
'org.mpris.MediaPlayer2': { "org.mpris.MediaPlayer2": {
'CanQuit': True, "CanQuit": True,
'CanRaise': True, "CanRaise": True,
'HasTrackList': True, "HasTrackList": True,
'Identity': 'Sublime Music', "Identity": "Sublime Music",
'DesktopEntry': 'sublime-music', "DesktopEntry": "sublime-music",
'SupportedUriSchemes': [], "SupportedUriSchemes": [],
'SupportedMimeTypes': [], "SupportedMimeTypes": [],
}, },
'org.mpris.MediaPlayer2.Player': { "org.mpris.MediaPlayer2.Player": {
'PlaybackStatus': { "PlaybackStatus": {
(False, False): 'Stopped', (False, False): "Stopped",
(False, True): 'Stopped', (False, True): "Stopped",
(True, False): 'Paused', (True, False): "Paused",
(True, True): 'Playing', (True, True): "Playing",
}[player is not None and player.song_loaded, state.playing], }[player is not None and player.song_loaded, state.playing],
'LoopStatus': "LoopStatus": state.repeat_type.as_mpris_loop_status(),
state.repeat_type.as_mpris_loop_status(), "Rate": 1.0,
'Rate': "Shuffle": state.shuffle_on,
1.0, "Metadata": self.get_mpris_metadata(
'Shuffle': state.current_song_index, state.play_queue,
state.shuffle_on, )
'Metadata': if state.current_song
self.get_mpris_metadata( else {},
state.current_song_index, "Volume": 0.0 if state.is_muted else state.volume / 100,
state.play_queue, "Position": (
) if state.current_song else {}, "x",
'Volume':
0.0 if state.is_muted else state.volume / 100,
'Position': (
'x',
int( int(
max(state.song_progress or 0, 0) max(state.song_progress or 0, 0)
* self.second_microsecond_conversion), * self.second_microsecond_conversion
), ),
'MinimumRate': ),
1.0, "MinimumRate": 1.0,
'MaximumRate': "MaximumRate": 1.0,
1.0, "CanGoNext": has_current_song and has_next_song,
'CanGoNext': "CanGoPrevious": has_current_song,
has_current_song and has_next_song, "CanPlay": True,
'CanGoPrevious': "CanPause": True,
has_current_song, "CanSeek": True,
'CanPlay': "CanControl": True,
True,
'CanPause':
True,
'CanSeek':
True,
'CanControl':
True,
}, },
'org.mpris.MediaPlayer2.TrackList': { "org.mpris.MediaPlayer2.TrackList": {
'Tracks': self.get_dbus_playlist(state.play_queue), "Tracks": self.get_dbus_playlist(state.play_queue),
'CanEditTracks': False, "CanEditTracks": False,
}, },
'org.mpris.MediaPlayer2.Playlists': { "org.mpris.MediaPlayer2.Playlists": {
'PlaylistCount': playlist_count, "PlaylistCount": playlist_count,
'Orderings': ['Alphabetical', 'Created', 'Modified'], "Orderings": ["Alphabetical", "Created", "Modified"],
'ActivePlaylist': ('(b(oss))', active_playlist), "ActivePlaylist": ("(b(oss))", active_playlist),
}, },
} }
def get_mpris_metadata( def get_mpris_metadata(self, idx: int, play_queue: List[str],) -> Dict[str, Any]:
self,
idx: int,
play_queue: List[str],
) -> Dict[str, Any]:
song_result = CacheManager.get_song_details(play_queue[idx]) song_result = CacheManager.get_song_details(play_queue[idx])
if song_result.is_future: if song_result.is_future:
return {} return {}
@@ -297,18 +269,18 @@ class DBusManager:
trackid = self.get_dbus_playlist(play_queue)[idx] trackid = self.get_dbus_playlist(play_queue)[idx]
duration = ( duration = (
'x', "x",
(song.duration or 0) * self.second_microsecond_conversion, (song.duration or 0) * self.second_microsecond_conversion,
) )
return { return {
'mpris:trackid': trackid, "mpris:trackid": trackid,
'mpris:length': duration, "mpris:length": duration,
'mpris:artUrl': CacheManager.get_cover_art_url(song.coverArt), "mpris:artUrl": CacheManager.get_cover_art_url(song.coverArt),
'xesam:album': song.album or '', "xesam:album": song.album or "",
'xesam:albumArtist': [song.artist or ''], "xesam:albumArtist": [song.artist or ""],
'xesam:artist': [song.artist or ''], "xesam:artist": [song.artist or ""],
'xesam:title': song.title, "xesam:title": song.title,
} }
def get_dbus_playlist(self, play_queue: List[str]) -> List[str]: def get_dbus_playlist(self, play_queue: List[str]) -> List[str]:
@@ -316,7 +288,7 @@ class DBusManager:
tracks = [] tracks = []
for song_id in play_queue: for song_id in play_queue:
id_ = seen_counts[song_id] id_ = seen_counts[song_id]
tracks.append(f'/song/{song_id}/{id_}') tracks.append(f"/song/{song_id}/{id_}")
seen_counts[song_id] += 1 seen_counts[song_id] += 1
return tracks return tracks
@@ -329,35 +301,36 @@ class DBusManager:
changes = defaultdict(dict) 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() 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 changes = new_property_dict
for interface, changed_props in changes.items(): for interface, changed_props in changes.items():
# If the metadata has changed, just make the entire Metadata object # If the metadata has changed, just make the entire Metadata object
# part of the update. # part of the update.
if 'Metadata' in changed_props.keys(): if "Metadata" in changed_props.keys():
changed_props['Metadata'] = new_property_dict[interface][ changed_props["Metadata"] = new_property_dict[interface]["Metadata"]
'Metadata']
# Special handling for when the position changes (a seek). # Special handling for when the position changes (a seek).
# Technically, I'm sending this signal too often, but I don't think # Technically, I'm sending this signal too often, but I don't think
# it really matters. # it really matters.
if (interface == 'org.mpris.MediaPlayer2.Player' if (
and 'Position' in changed_props): interface == "org.mpris.MediaPlayer2.Player"
and "Position" in changed_props
):
self.connection.emit_signal( self.connection.emit_signal(
None, None,
'/org/mpris/MediaPlayer2', "/org/mpris/MediaPlayer2",
interface, interface,
'Seeked', "Seeked",
GLib.Variant('(x)', (changed_props['Position'][1], )), GLib.Variant("(x)", (changed_props["Position"][1],)),
) )
# Do not emit the property change. # Do not emit the property change.
del changed_props['Position'] del changed_props["Position"]
# Special handling for when the track list changes. # Special handling for when the track list changes.
# Technically, I'm supposed to use `TrackAdded` and `TrackRemoved` # 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 # So I think that any change is invasive enough that I should use
# this signal. # this signal.
if (interface == 'org.mpris.MediaPlayer2.TrackList' if (
and 'Tracks' in changed_props): interface == "org.mpris.MediaPlayer2.TrackList"
track_list = changed_props['Tracks'] and "Tracks" in changed_props
):
track_list = changed_props["Tracks"]
if len(track_list) > 0: if len(track_list) > 0:
current_track = ( current_track = new_property_dict["org.mpris.MediaPlayer2.Player"][
new_property_dict['org.mpris.MediaPlayer2.Player'] "Metadata"
['Metadata'].get('mpris:trackid', track_list[0])) ].get("mpris:trackid", track_list[0])
self.connection.emit_signal( self.connection.emit_signal(
None, None,
'/org/mpris/MediaPlayer2', "/org/mpris/MediaPlayer2",
interface, interface,
'TrackListReplaced', "TrackListReplaced",
GLib.Variant('(aoo)', (track_list, current_track)), GLib.Variant("(aoo)", (track_list, current_track)),
) )
self.connection.emit_signal( self.connection.emit_signal(
None, None,
'/org/mpris/MediaPlayer2', "/org/mpris/MediaPlayer2",
'org.freedesktop.DBus.Properties', "org.freedesktop.DBus.Properties",
'PropertiesChanged', "PropertiesChanged",
GLib.Variant( GLib.Variant(
'(sa{sv}as)', ( "(sa{sv}as)",
(
interface, interface,
{ {
k: DBusManager.to_variant(v) k: DBusManager.to_variant(v)
for k, v in changed_props.items() for k, v in changed_props.items()
}, },
[], [],
)), ),
),
) )
# Update state for next diff. # Update state for next diff.

View File

@@ -8,13 +8,11 @@ from dateutil import parser
def from_json(template_type: Any, data: Any) -> Any: def from_json(template_type: Any, data: Any) -> Any:
""" """
Converts data from a JSON parse into an instantiation of the Python object Converts data from a JSON parse into an instantiation of the Python object specified
specified by template_type. by template_type.
Arguments: :param template_type: the template type to deserialize into
:param data: the data to deserialize to the class
template_type: the template type to deserialize into
data: the data to deserialize to the class
""" """
# Approach for deserialization here: # Approach for deserialization here:
# https://stackoverflow.com/a/40639688/2319844 # 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 if isinstance(template_type, typing.ForwardRef): # type: ignore
template_type = template_type._evaluate(globals(), locals()) template_type = template_type._evaluate(globals(), locals())
annotations: Dict[str, annotations: Dict[str, Type] = getattr(template_type, "__annotations__", {})
Type] = getattr(template_type, '__annotations__', {})
# Handle primitive of objects # Handle primitive of objects
instance: Any = None instance: Any = None
@@ -33,7 +30,7 @@ def from_json(template_type: Any, data: Any) -> Any:
instance = None instance = None
# Handle generics. List[*], Dict[*, *] in particular. # Handle generics. List[*], Dict[*, *] in particular.
elif type(template_type) == typing._GenericAlias: # type: ignore 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] template_type = template_type.__args__[0]
instance = from_json(template_type, data) instance = from_json(template_type, data)
else: 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 # This is not very elegant since it doesn't allow things which
# sublass from List or Dict. For my purposes, this doesn't matter. # 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] inner_type = template_type.__args__[0]
instance = [from_json(inner_type, value) for value in data] 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__ key_type, val_type = template_type.__args__
instance = { instance = {
from_json(key_type, key): from_json(val_type, value) from_json(key_type, key): from_json(val_type, value)
@@ -54,8 +51,10 @@ def from_json(template_type: Any, data: Any) -> Any:
} }
else: else:
raise Exception( raise Exception(
'Trying to deserialize an unsupported type: {}'.format( "Trying to deserialize an unsupported type: {}".format(
template_type._name)) template_type._name
)
)
elif template_type == str or issubclass(template_type, str): elif template_type == str or issubclass(template_type, str):
instance = data instance = data
elif template_type == int or issubclass(template_type, int): 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) instance = bool(data)
elif type(template_type) == EnumMeta: elif type(template_type) == EnumMeta:
if type(data) == dict: if type(data) == dict:
instance = template_type(data.get('_value_')) instance = template_type(data.get("_value_"))
else: else:
instance = template_type(data) instance = template_type(data)
elif template_type == datetime: 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)) field: from_json(field_type, data.get(field))
for field, field_type in annotations.items() for field, field_type in annotations.items()
}) }
)
return instance return instance

View File

@@ -74,52 +74,58 @@ class Player:
self._set_is_muted(value) self._set_is_muted(value)
def reset(self): def reset(self):
raise NotImplementedError( raise NotImplementedError("reset must be implemented by implementor of Player")
'reset must be implemented by implementor of Player')
def play_media(self, file_or_url: str, progress: float, song: Child): def play_media(self, file_or_url: str, progress: float, song: Child):
raise NotImplementedError( raise NotImplementedError(
'play_media must be implemented by implementor of Player') "play_media must be implemented by implementor of Player"
)
def _is_playing(self): def _is_playing(self):
raise NotImplementedError( raise NotImplementedError(
'_is_playing must be implemented by implementor of Player') "_is_playing must be implemented by implementor of Player"
)
def pause(self): def pause(self):
raise NotImplementedError( raise NotImplementedError("pause must be implemented by implementor of Player")
'pause must be implemented by implementor of Player')
def toggle_play(self): def toggle_play(self):
raise NotImplementedError( 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): def seek(self, value: float):
raise NotImplementedError( raise NotImplementedError("seek must be implemented by implementor of Player")
'seek must be implemented by implementor of Player')
def _get_timepos(self): def _get_timepos(self):
raise NotImplementedError( raise NotImplementedError(
'get_timepos must be implemented by implementor of Player') "get_timepos must be implemented by implementor of Player"
)
def _get_volume(self): def _get_volume(self):
raise NotImplementedError( 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): def _set_volume(self, value: float):
raise NotImplementedError( 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): def _get_is_muted(self):
raise NotImplementedError( 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): def _set_is_muted(self, value: bool):
raise NotImplementedError( 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): def shutdown(self):
raise NotImplementedError( raise NotImplementedError(
'shutdown must be implemented by implementor of Player') "shutdown must be implemented by implementor of Player"
)
class MPVPlayer(Player): class MPVPlayer(Player):
@@ -130,19 +136,18 @@ class MPVPlayer(Player):
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
config: AppConfiguration, config: AppConfiguration,
): ):
super().__init__( super().__init__(on_timepos_change, on_track_end, on_player_event, config)
on_timepos_change, on_track_end, on_player_event, config)
self.mpv = mpv.MPV() 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.mpv.replaygain = config.replay_gain.as_string()
self.progress_value_lock = threading.Lock() self.progress_value_lock = threading.Lock()
self.progress_value_count = 0 self.progress_value_count = 0
self._muted = False self._muted = False
self._volume = 100. self._volume = 100.0
self._can_hotswap_source = True self._can_hotswap_source = True
@self.mpv.property_observer('time-pos') @self.mpv.property_observer("time-pos")
def time_observer(_: Any, value: Optional[float]): def time_observer(_: Any, value: Optional[float]):
self.on_timepos_change(value) self.on_timepos_change(value)
if value is None and self.progress_value_count > 1: if value is None and self.progress_value_count > 1:
@@ -169,10 +174,7 @@ class MPVPlayer(Player):
self.mpv.pause = False self.mpv.pause = False
self.mpv.command( self.mpv.command(
'loadfile', "loadfile", file_or_url, "replace", f"start={progress}" if progress else "",
file_or_url,
'replace',
f'start={progress}' if progress else '',
) )
self._song_loaded = True self._song_loaded = True
@@ -180,10 +182,10 @@ class MPVPlayer(Player):
self.mpv.pause = True self.mpv.pause = True
def toggle_play(self): def toggle_play(self):
self.mpv.cycle('pause') self.mpv.cycle("pause")
def seek(self, value: float): def seek(self, value: float):
self.mpv.seek(str(value), 'absolute') self.mpv.seek(str(value), "absolute")
def _get_volume(self) -> float: def _get_volume(self) -> float:
return self._volume return self._volume
@@ -236,31 +238,30 @@ class ChromecastPlayer(Player):
self.app = bottle.Bottle() self.app = bottle.Bottle()
@self.app.route('/') @self.app.route("/")
def index() -> str: def index() -> str:
return ''' return """
<h1>Sublime Music Local Music Server</h1> <h1>Sublime Music Local Music Server</h1>
<p> <p>
Sublime Music uses this port as a server for serving music Sublime Music uses this port as a server for serving music
Chromecasts on the same LAN. Chromecasts on the same LAN.
</p> </p>
''' """
@self.app.route('/s/<token>') @self.app.route("/s/<token>")
def stream_song(token: str) -> bytes: def stream_song(token: str) -> bytes:
if token != self.token: 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() song = CacheManager.get_song_details(self.song_id).result()
filename, _ = CacheManager.get_song_filename_or_stream(song) 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()) song_buffer = io.BytesIO(fin.read())
bottle.response.set_header( bottle.response.set_header(
'Content-Type', "Content-Type", mimetypes.guess_type(filename)[0],
mimetypes.guess_type(filename)[0],
) )
bottle.response.set_header('Accept-Ranges', 'bytes') bottle.response.set_header("Accept-Ranges", "bytes")
return song_buffer.read() return song_buffer.read()
def set_song_and_token(self, song_id: str, token: str): def set_song_and_token(self, song_id: str, token: str):
@@ -276,11 +277,11 @@ class ChromecastPlayer(Player):
def get_chromecasts(cls) -> Future: def get_chromecasts(cls) -> Future:
def do_get_chromecasts() -> List[pychromecast.Chromecast]: def do_get_chromecasts() -> List[pychromecast.Chromecast]:
if not ChromecastPlayer.getting_chromecasts: if not ChromecastPlayer.getting_chromecasts:
logging.info('Getting Chromecasts') logging.info("Getting Chromecasts")
ChromecastPlayer.getting_chromecasts = True ChromecastPlayer.getting_chromecasts = True
ChromecastPlayer.chromecasts = pychromecast.get_chromecasts() ChromecastPlayer.chromecasts = pychromecast.get_chromecasts()
else: else:
logging.info('Already getting Chromecasts... busy wait') logging.info("Already getting Chromecasts... busy wait")
while ChromecastPlayer.getting_chromecasts: while ChromecastPlayer.getting_chromecasts:
sleep(0.1) sleep(0.1)
@@ -291,15 +292,15 @@ class ChromecastPlayer(Player):
def set_playing_chromecast(self, uuid: str): def set_playing_chromecast(self, uuid: str):
self.chromecast = next( self.chromecast = next(
cc for cc in ChromecastPlayer.chromecasts cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid)
if cc.device.uuid == UUID(uuid)) )
self.chromecast.media_controller.register_status_listener( self.chromecast.media_controller.register_status_listener(
ChromecastPlayer.media_status_listener) ChromecastPlayer.media_status_listener
self.chromecast.register_status_listener( )
ChromecastPlayer.cast_status_listener) self.chromecast.register_status_listener(ChromecastPlayer.cast_status_listener)
self.chromecast.wait() self.chromecast.wait()
logging.info(f'Using: {self.chromecast.device.friendly_name}') logging.info(f"Using: {self.chromecast.device.friendly_name}")
def __init__( def __init__(
self, self,
@@ -308,16 +309,17 @@ class ChromecastPlayer(Player):
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
config: AppConfiguration, config: AppConfiguration,
): ):
super().__init__( super().__init__(on_timepos_change, on_track_end, on_player_event, config)
on_timepos_change, on_track_end, on_player_event, config)
self._timepos = 0.0 self._timepos = 0.0
self.time_incrementor_running = False self.time_incrementor_running = False
self._can_hotswap_source = False self._can_hotswap_source = False
ChromecastPlayer.cast_status_listener.on_new_cast_status = ( 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 = ( ChromecastPlayer.media_status_listener.on_new_media_status = (
self.on_new_media_status) self.on_new_media_status
)
# Set host_ip # Set host_ip
# TODO (#128): should have a mechanism to update this. Maybe it should # TODO (#128): should have a mechanism to update this. Maybe it should
@@ -326,7 +328,7 @@ class ChromecastPlayer(Player):
# piped over the VPN tunnel. # piped over the VPN tunnel.
try: try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 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] self.host_ip = s.getsockname()[0]
s.close() s.close()
except OSError: except OSError:
@@ -336,42 +338,43 @@ class ChromecastPlayer(Player):
self.serve_over_lan = config.serve_over_lan self.serve_over_lan = config.serve_over_lan
if self.serve_over_lan: if self.serve_over_lan:
self.server_thread = ChromecastPlayer.ServerThread( self.server_thread = ChromecastPlayer.ServerThread("0.0.0.0", self.port)
'0.0.0.0', self.port)
self.server_thread.start() self.server_thread.start()
def on_new_cast_status( def on_new_cast_status(
self, self, status: pychromecast.socket_client.CastStatus,
status: pychromecast.socket_client.CastStatus,
): ):
self.on_player_event( self.on_player_event(
PlayerEvent( PlayerEvent(
'volume_change', "volume_change",
status.volume_level * 100 if not status.volume_muted else 0, status.volume_level * 100 if not status.volume_muted else 0,
)) )
)
# This normally happens when "Stop Casting" is pressed in the Google # This normally happens when "Stop Casting" is pressed in the Google
# Home app. # Home app.
if status.session_id is None: 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 self._song_loaded = False
def on_new_media_status( def on_new_media_status(
self, self, status: pychromecast.controllers.media.MediaStatus,
status: pychromecast.controllers.media.MediaStatus,
): ):
# Detect the end of a track and go to the next one. # Detect the end of a track and go to the next one.
if (status.idle_reason == 'FINISHED' and status.player_state == 'IDLE' if (
and self._timepos > 0): status.idle_reason == "FINISHED"
and status.player_state == "IDLE"
and self._timepos > 0
):
self.on_track_end() self.on_track_end()
self._timepos = status.current_time self._timepos = status.current_time
self.on_player_event( self.on_player_event(
PlayerEvent( PlayerEvent(
'play_state_change', "play_state_change", status.player_state in ("PLAYING", "BUFFERING"),
status.player_state in ('PLAYING', 'BUFFERING'), )
)) )
# Start the time incrementor just in case this was a play notification. # Start the time incrementor just in case this was a play notification.
self.start_time_incrementor() self.start_time_incrementor()
@@ -400,8 +403,7 @@ class ChromecastPlayer(Player):
if self.playing: if self.playing:
break break
if url is not None: if url is not None:
if (url == self.chromecast.media_controller.status if url == self.chromecast.media_controller.status.content_id:
.content_id):
break break
callback() 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 it's a local file, then see if we can serve it over the LAN.
if not stream_scheme: if not stream_scheme:
if self.serve_over_lan: if self.serve_over_lan:
token = base64.b64encode(os.urandom(64)).decode('ascii') token = base64.b64encode(os.urandom(64)).decode("ascii")
for r in (('+', '.'), ('/', '-'), ('=', '_')): for r in (("+", "."), ("/", "-"), ("=", "_")):
token = token.replace(*r) token = token.replace(*r)
self.server_thread.set_song_and_token(song.id, token) 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: else:
file_or_url, _ = CacheManager.get_song_filename_or_stream( file_or_url, _ = CacheManager.get_song_filename_or_stream(
song, song, force_stream=True,
force_stream=True,
) )
cover_art_url = CacheManager.get_cover_art_url(song.coverArt) cover_art_url = CacheManager.get_cover_art_url(song.coverArt)
self.chromecast.media_controller.play_media( self.chromecast.media_controller.play_media(
file_or_url, file_or_url,
# Just pretend that whatever we send it is mp3, even if it isn't. # Just pretend that whatever we send it is mp3, even if it isn't.
'audio/mp3', "audio/mp3",
current_time=progress, current_time=progress,
title=song.title, title=song.title,
thumb=cover_art_url, thumb=cover_art_url,
metadata={ metadata={
'metadataType': 3, "metadataType": 3,
'albumName': song.album, "albumName": song.album,
'artist': song.artist, "artist": song.artist,
'trackNumber': song.track, "trackNumber": song.track,
}, },
) )
self._timepos = progress self._timepos = progress

View File

@@ -3,4 +3,4 @@ This module defines a stateless server which interops with the Subsonic API.
""" """
from .server import Server from .server import Server
__all__ = ('Server', ) __all__ = ("Server",)

View File

@@ -6,15 +6,15 @@ from sublime.from_json import from_json as _from_json
class APIObject: class APIObject:
"""Defines the base class for objects coming from the Subsonic API.""" """Defines the base class for objects coming from the Subsonic API."""
@classmethod @classmethod
def from_json(cls, data: Dict[str, Any]) -> Any: def from_json(cls, data: Dict[str, Any]) -> Any:
""" """
Creates an :class:`APIObject` by taking the ``data`` and passing it to Creates an :class:`APIObject` by taking the ``data`` and passing it to the class
the class constructor and then recursively calling ``from_json`` on all constructor and then recursively calling ``from_json`` on all of the fields.
of the fields. ``data`` just has to be a well-formed :class:`dict`, so ``data`` just has to be a well-formed :class:`dict`, so it can come from the
it can come from the JSON or XML APIs. JSON or XML APIs.
:param data: a Python dictionary representation of the data to :param data: a Python dictionary representation of the data to deserialize
deserialize
""" """
return _from_json(cls, data) return _from_json(cls, data)

View File

@@ -33,10 +33,10 @@ class AverageRating(APIObject, float):
class MediaType(APIObject, Enum): class MediaType(APIObject, Enum):
MUSIC = 'music' MUSIC = "music"
PODCAST = 'podcast' PODCAST = "podcast"
AUDIOBOOK = 'audiobook' AUDIOBOOK = "audiobook"
VIDEO = 'video' VIDEO = "video"
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default) return getattr(self, key, default)
@@ -451,12 +451,12 @@ class MusicFolders(APIObject):
class PodcastStatus(APIObject, Enum): class PodcastStatus(APIObject, Enum):
NEW = 'new' NEW = "new"
DOWNLOADING = 'downloading' DOWNLOADING = "downloading"
COMPLETED = 'completed' COMPLETED = "completed"
ERROR = 'error' ERROR = "error"
DELETED = 'deleted' DELETED = "deleted"
SKIPPED = 'skipped' SKIPPED = "skipped"
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default) return getattr(self, key, default)
@@ -656,8 +656,8 @@ class Podcasts(APIObject):
class ResponseStatus(APIObject, Enum): class ResponseStatus(APIObject, Enum):
OK = 'ok' OK = "ok"
FAILED = 'failed' FAILED = "failed"
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
return getattr(self, key, default) return getattr(self, key, default)

View File

@@ -61,9 +61,10 @@ class Server:
* The ``server`` module is stateless. The only thing that it does is allow * 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. the module's user to query the \\*sonic server via the API.
""" """
class SubsonicServerError(Exception): class SubsonicServerError(Exception):
def __init__(self: 'Server.SubsonicServerError', error: Error): def __init__(self: "Server.SubsonicServerError", error: Error):
super().__init__(f'{error.code}: {error.message}') super().__init__(f"{error.code}: {error.message}")
def __init__( def __init__(
self, self,
@@ -82,26 +83,27 @@ class Server:
def _get_params(self) -> Dict[str, str]: def _get_params(self) -> Dict[str, str]:
"""See Subsonic API Introduction for details.""" """See Subsonic API Introduction for details."""
return { return {
'u': self.username, "u": self.username,
'p': self.password, "p": self.password,
'c': 'Sublime Music', "c": "Sublime Music",
'f': 'json', "f": "json",
'v': '1.15.0', "v": "1.15.0",
} }
def _make_url(self, endpoint: str) -> str: 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, timeout=(3.05, 2), **params):
def _get(self, url: str, **params) -> Any: def _get(self, url: str, **params) -> Any:
params = {**self._get_params(), **params} 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( logging.info(
"SUBLIME_MUSIC_DEBUG_DELAY enabled. Pausing for " "SUBLIME_MUSIC_DEBUG_DELAY enabled. Pausing for "
f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds.") f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds."
sleep(float(os.environ['SUBLIME_MUSIC_DEBUG_DELAY'])) )
sleep(float(os.environ["SUBLIME_MUSIC_DEBUG_DELAY"]))
# Deal with datetime parameters (convert to milliseconds since 1970) # Deal with datetime parameters (convert to milliseconds since 1970)
for k, v in params.items(): for k, v in params.items():
@@ -116,15 +118,13 @@ class Server:
) )
# TODO (#122): make better # TODO (#122): make better
if result.status_code != 200: 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 return result
def _get_json( def _get_json(
self, self, url: str, **params: Union[None, str, datetime, int, List[int]],
url: str,
**params: Union[None, str, datetime, int, List[int]],
) -> Response: ) -> Response:
""" """
Make a get request to a *Sonic REST API. Handle all types of errors 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 :raises Exception: needs some work TODO
""" """
result = self._get(url, **params) result = self._get(url, **params)
subsonic_response = result.json().get('subsonic-response') subsonic_response = result.json().get("subsonic-response")
# TODO (#122): make better # TODO (#122): make better
if not subsonic_response: 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 = ( code, message = (
subsonic_response['error'].get('code'), subsonic_response["error"].get("code"),
subsonic_response['error'].get('message'), 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) response = Response.from_json(subsonic_response)
@@ -159,8 +159,8 @@ class Server:
def do_download(self, url: str, **params) -> bytes: def do_download(self, url: str, **params) -> bytes:
download = self._get(url, **params) download = self._get(url, **params)
if not download: if not download:
raise Exception('Download failed') raise Exception("Download failed")
if 'json' in download.headers.get('Content-Type'): if "json" in download.headers.get("Content-Type"):
# TODO (#122): make better # TODO (#122): make better
raise Exception("Didn't expect JSON.") raise Exception("Didn't expect JSON.")
return download.content return download.content
@@ -169,20 +169,18 @@ class Server:
""" """
Used to test connectivity with the 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: def get_license(self) -> License:
"""Get details about the software 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: def get_music_folders(self) -> MusicFolders:
"""Returns all configured top-level music folders.""" """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( def get_indexes(
self, self, music_folder_id: int = None, if_modified_since: int = None,
music_folder_id: int = None,
if_modified_since: int = None,
) -> Indexes: ) -> Indexes:
""" """
Returns an indexed structure of all artists. Returns an indexed structure of all artists.
@@ -193,7 +191,7 @@ class Server:
artist collection has changed since the given time. artist collection has changed since the given time.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getIndexes'), self._make_url("getIndexes"),
musicFolderId=music_folder_id, musicFolderId=music_folder_id,
ifModifiedSince=if_modified_since, ifModifiedSince=if_modified_since,
) )
@@ -207,15 +205,12 @@ class Server:
:param dir_id: A string which uniquely identifies the music folder. :param dir_id: A string which uniquely identifies the music folder.
Obtained by calls to ``getIndexes`` or ``getMusicDirectory``. Obtained by calls to ``getIndexes`` or ``getMusicDirectory``.
""" """
result = self._get_json( result = self._get_json(self._make_url("getMusicDirectory"), id=str(dir_id),)
self._make_url('getMusicDirectory'),
id=str(dir_id),
)
return result.directory return result.directory
def get_genres(self) -> Genres: def get_genres(self) -> Genres:
"""Returns all 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: def get_artists(self, music_folder_id: int = None) -> ArtistsID3:
""" """
@@ -225,8 +220,7 @@ class Server:
folder with the given ID. See ``getMusicFolders``. folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getArtists'), self._make_url("getArtists"), musicFolderId=music_folder_id,
musicFolderId=music_folder_id,
) )
return result.artists return result.artists
@@ -237,7 +231,7 @@ class Server:
:param artist_id: The artist ID. :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: def get_album(self, album_id: int) -> AlbumWithSongsID3:
""" """
@@ -246,7 +240,7 @@ class Server:
:param album_id: The album ID. :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: def get_song(self, song_id: int) -> Child:
""" """
@@ -254,13 +248,13 @@ class Server:
:param song_id: The song ID. :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]]: def get_videos(self) -> Optional[List[Child]]:
""" """
Returns all video files. 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]: def get_video_info(self, video_id: int) -> Optional[VideoInfo]:
""" """
@@ -269,14 +263,11 @@ class Server:
:param video_id: The video ID. :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 return result.videoInfo
def get_artist_info( def get_artist_info(
self, self, id: int, count: int = None, include_not_present: bool = None,
id: int,
count: int = None,
include_not_present: bool = None,
) -> Optional[ArtistInfo]: ) -> Optional[ArtistInfo]:
""" """
Returns artist info with biography, image URLs and similar artists, Returns artist info with biography, image URLs and similar artists,
@@ -290,7 +281,7 @@ class Server:
Spec. Spec.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getArtistInfo'), self._make_url("getArtistInfo"),
id=id, id=id,
count=count, count=count,
includeNotPresent=include_not_present, includeNotPresent=include_not_present,
@@ -298,10 +289,7 @@ class Server:
return result.artistInfo return result.artistInfo
def get_artist_info2( def get_artist_info2(
self, self, id: int, count: int = None, include_not_present: bool = None,
id: int,
count: int = None,
include_not_present: bool = None,
) -> Optional[ArtistInfo2]: ) -> Optional[ArtistInfo2]:
""" """
Similar to getArtistInfo, but organizes music according to ID3 tags. Similar to getArtistInfo, but organizes music according to ID3 tags.
@@ -314,7 +302,7 @@ class Server:
Spec. Spec.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getArtistInfo2'), self._make_url("getArtistInfo2"),
id=id, id=id,
count=count, count=count,
includeNotPresent=include_not_present, includeNotPresent=include_not_present,
@@ -327,7 +315,7 @@ class Server:
:param id: The album or song ID. :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 return result.albumInfo
def get_album_info2(self, id: int) -> Optional[AlbumInfo]: def get_album_info2(self, id: int) -> Optional[AlbumInfo]:
@@ -336,7 +324,7 @@ class Server:
:param id: The album or song ID. :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 return result.albumInfo
def get_similar_songs(self, id: int, count: int = None) -> List[Child]: 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 :param count: Max number of songs to return. Defaults to 50 according
to API Spec. to API Spec.
""" """
result = self._get_json( result = self._get_json(self._make_url("getSimilarSongs"), id=id, count=count,)
self._make_url('getSimilarSongs'),
id=id,
count=count,
)
return result.similarSongs.song return result.similarSongs.song
def get_similar_songs2(self, id: int, count: int = None) -> List[Child]: 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 :param count: Max number of songs to return. Defaults to 50 according
to API Spec. to API Spec.
""" """
result = self._get_json( result = self._get_json(self._make_url("getSimilarSongs2"), id=id, count=count,)
self._make_url('getSimilarSongs2'),
id=id,
count=count,
)
return result.similarSongs2.song return result.similarSongs2.song
def get_top_songs(self, artist: str, count: int = None) -> List[Child]: def get_top_songs(self, artist: str, count: int = None) -> List[Child]:
@@ -380,9 +360,7 @@ class Server:
to API Spec. to API Spec.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getTopSongs'), self._make_url("getTopSongs"), artist=artist, count=count,
artist=artist,
count=count,
) )
return result.topSongs.song return result.topSongs.song
@@ -422,7 +400,7 @@ class Server:
folder with the given ID. See ``getMusicFolders``. folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getAlbumList'), self._make_url("getAlbumList"),
type=type, type=type,
size=size, size=size,
offset=offset, offset=offset,
@@ -467,7 +445,7 @@ class Server:
folder with the given ID. See ``getMusicFolders``. folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getAlbumList2'), self._make_url("getAlbumList2"),
type=type, type=type,
size=size, size=size,
offset=offset, offset=offset,
@@ -498,7 +476,7 @@ class Server:
given ID. See ``getMusicFolders``. given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getRandomSongs'), self._make_url("getRandomSongs"),
size=size, size=size,
genre=genre, genre=genre,
fromYear=from_year, fromYear=from_year,
@@ -526,7 +504,7 @@ class Server:
folder with the given ID. See ``getMusicFolders``. folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getSongsByGenre'), self._make_url("getSongsByGenre"),
genre=genre, genre=genre,
count=count, count=count,
offset=offset, offset=offset,
@@ -539,7 +517,7 @@ class Server:
Returns what is currently being played by all users. Takes no extra Returns what is currently being played by all users. Takes no extra
parameters. 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: def get_starred(self, music_folder_id: int = None) -> Starred:
""" """
@@ -549,8 +527,7 @@ class Server:
music folder with the given ID. See ``getMusicFolders``. music folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getStarred'), self._make_url("getStarred"), musicFolderId=music_folder_id,
musicFolderId=music_folder_id,
) )
return result.starred return result.starred
@@ -562,12 +539,11 @@ class Server:
music folder with the given ID. See ``getMusicFolders``. music folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getStarred2'), self._make_url("getStarred2"), musicFolderId=music_folder_id,
musicFolderId=music_folder_id,
) )
return result.starred2 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( def search(
self, self,
artist: str = None, artist: str = None,
@@ -591,15 +567,14 @@ class Server:
:param newer_than: Only return matches that are newer than this. :param newer_than: Only return matches that are newer than this.
""" """
result = self._get_json( result = self._get_json(
self._make_url('search'), self._make_url("search"),
artist=artist, artist=artist,
album=album, album=album,
title=title, title=title,
any=any, any=any,
count=count, count=count,
offset=offset, offset=offset,
newerThan=math.floor(newer_than.timestamp() newerThan=math.floor(newer_than.timestamp() * 1000) if newer_than else None,
* 1000) if newer_than else None,
) )
return result.searchResult return result.searchResult
@@ -635,7 +610,7 @@ class Server:
music folder with the given ID. See ``getMusicFolders``. music folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('search2'), self._make_url("search2"),
query=query, query=query,
artistCount=artist_count, artistCount=artist_count,
artistOffset=artist_offset, artistOffset=artist_offset,
@@ -678,7 +653,7 @@ class Server:
music folder with the given ID. See ``getMusicFolders``. music folder with the given ID. See ``getMusicFolders``.
""" """
result = self._get_json( result = self._get_json(
self._make_url('search3'), self._make_url("search3"),
query=query, query=query,
artistCount=artist_count, artistCount=artist_count,
artistOffset=artist_offset, artistOffset=artist_offset,
@@ -698,10 +673,7 @@ class Server:
user rather than for the authenticated user. The authenticated user user rather than for the authenticated user. The authenticated user
must have admin role if this parameter is used. must have admin role if this parameter is used.
""" """
result = self._get_json( result = self._get_json(self._make_url("getPlaylists"), username=username,)
self._make_url('getPlaylists'),
username=username,
)
return result.playlists return result.playlists
def get_playlist(self, id: int) -> PlaylistWithSongs: def get_playlist(self, id: int) -> PlaylistWithSongs:
@@ -711,7 +683,7 @@ class Server:
:param username: ID of the playlist to return, as obtained by :param username: ID of the playlist to return, as obtained by
``getPlaylists``. ``getPlaylists``.
""" """
result = self._get_json(self._make_url('getPlaylist'), id=id) result = self._get_json(self._make_url("getPlaylist"), id=id)
return result.playlist return result.playlist
def create_playlist( def create_playlist(
@@ -730,7 +702,7 @@ class Server:
a list of IDs. a list of IDs.
""" """
result = self._get_json( result = self._get_json(
self._make_url('createPlaylist'), self._make_url("createPlaylist"),
playlistId=playlist_id, playlistId=playlist_id,
name=name, name=name,
songId=song_id, songId=song_id,
@@ -762,7 +734,7 @@ class Server:
the playlist. Can be a single ID or a list of IDs. the playlist. Can be a single ID or a list of IDs.
""" """
return self._get_json( return self._get_json(
self._make_url('updatePlaylist'), self._make_url("updatePlaylist"),
playlistId=playlist_id, playlistId=playlist_id,
name=name, name=name,
comment=comment, comment=comment,
@@ -773,7 +745,7 @@ class Server:
def delete_playlist(self, id: int) -> Response: def delete_playlist(self, id: int) -> Response:
"""Deletes a saved playlist.""" """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( def get_stream_url(
self, self,
@@ -824,7 +796,7 @@ class Server:
converted=converted, converted=converted,
) )
params = {k: v for k, v in params.items() if v} 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: def download(self, id: str) -> bytes:
""" """
@@ -834,7 +806,7 @@ class Server:
:param id: A string which uniquely identifies the file to stream. :param id: A string which uniquely identifies the file to stream.
Obtained by calls to ``getMusicDirectory``. 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: 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 id: The ID of a song, album or artist.
:param size: If specified, scale image to this size. :param size: If specified, scale image to this size.
""" """
return self.do_download( return self.do_download(self._make_url("getCoverArt"), id=id, size=size,)
self._make_url('getCoverArt'),
id=id,
size=size,
)
def get_cover_art_url(self, id: str, size: int = 1000) -> str: 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 = dict(**self._get_params(), id=id, size=size)
params = {k: v for k, v in params.items() if v} 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: def get_lyrics(self, artist: str = None, title: str = None) -> Lyrics:
""" """
@@ -868,9 +836,7 @@ class Server:
:param title: The song title. :param title: The song title.
""" """
result = self._get_json( result = self._get_json(
self._make_url('getLyrics'), self._make_url("getLyrics"), artist=artist, title=title,
artist=artist,
title=title,
) )
return result.lyrics return result.lyrics
@@ -880,7 +846,7 @@ class Server:
:param username: the user in question. :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( def star(
self, self,
@@ -903,10 +869,7 @@ class Server:
ID or a list of IDs. ID or a list of IDs.
""" """
return self._get_json( return self._get_json(
self._make_url('star'), self._make_url("star"), id=id, albumId=album_id, artistId=artist_id,
id=id,
albumId=album_id,
artistId=artist_id,
) )
def unstar( def unstar(
@@ -930,10 +893,7 @@ class Server:
ID or a list of IDs. ID or a list of IDs.
""" """
return self._get_json( return self._get_json(
self._make_url('unstar'), self._make_url("unstar"), id=id, albumId=album_id, artistId=artist_id,
id=id,
albumId=album_id,
artistId=artist_id,
) )
def set_rating(self, id: int, rating: int) -> Response: 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 :param rating: The rating between 1 and 5 (inclusive), or 0 to remove
the rating. the rating.
""" """
return self._get_json( return self._get_json(self._make_url("setRating"), id=id, rating=rating,)
self._make_url('setRating'),
id=id,
rating=rating,
)
def scrobble( def scrobble(
self, self, id: int, time: datetime = None, submission: bool = True,
id: int,
time: datetime = None,
submission: bool = True,
) -> Response: ) -> Response:
""" """
Registers the local playback of one or more media files. Typically used Registers the local playback of one or more media files. Typically used
@@ -980,10 +933,7 @@ class Server:
notification. notification.
""" """
return self._get_json( return self._get_json(
self._make_url('scrobble'), self._make_url("scrobble"), id=id, time=time, submission=submission,
id=id,
time=time,
submission=submission,
) )
def get_shares(self) -> Shares: def get_shares(self) -> Shares:
@@ -991,7 +941,7 @@ class Server:
Returns information about shared media this user is allowed to manage. Returns information about shared media this user is allowed to manage.
Takes no extra parameters. 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( def create_share(
self, self,
@@ -1013,7 +963,7 @@ class Server:
:param expires: The time at which the share expires. :param expires: The time at which the share expires.
""" """
result = self._get_json( result = self._get_json(
self._make_url('createShare'), self._make_url("createShare"),
id=id, id=id,
description=description, description=description,
expires=expires, expires=expires,
@@ -1021,10 +971,7 @@ class Server:
return result.shares return result.shares
def update_share( def update_share(
self, self, id: int, description: str = None, expires: datetime = None,
id: int,
description: str = None,
expires: datetime = None,
) -> Response: ) -> Response:
""" """
Updates the description and/or expiration date for an existing share. 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. :param expires: The time at which the share expires.
""" """
return self._get_json( return self._get_json(
self._make_url('updateShare'), self._make_url("updateShare"),
id=id, id=id,
description=description, description=description,
expires=expires, expires=expires,
@@ -1047,18 +994,15 @@ class Server:
:param id: ID of the share to delete. :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: def get_internet_radio_stations(self) -> InternetRadioStations:
"""Returns all internet radio stations.""" """Returns all internet radio stations."""
result = self._get_json(self._make_url('getInternetRadioStations')) result = self._get_json(self._make_url("getInternetRadioStations"))
return result.internetRadioStations return result.internetRadioStations
def create_internet_radio_station( def create_internet_radio_station(
self, self, stream_url: str, name: str, homepage_url: str = None,
stream_url: str,
name: str,
homepage_url: str = None,
) -> Response: ) -> Response:
""" """
Adds a new internet radio station. Only users with admin privileges are 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. :param homepage_url: The home page URL for the station.
""" """
return self._get_json( return self._get_json(
self._make_url('createInternetRadioStation'), self._make_url("createInternetRadioStation"),
streamUrl=stream_url, streamUrl=stream_url,
name=name, name=name,
homepageUrl=homepage_url, homepageUrl=homepage_url,
) )
def update_internet_radio_station( def update_internet_radio_station(
self, self, id: int, stream_url: str, name: str, homepage_url: str = None,
id: int,
stream_url: str,
name: str,
homepage_url: str = None,
) -> Response: ) -> Response:
""" """
Updates an existing internet radio station. Only users with admin 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. :param homepage_url: The home page URL for the station.
""" """
return self._get_json( return self._get_json(
self._make_url('updateInternetRadioStation'), self._make_url("updateInternetRadioStation"),
id=id, id=id,
streamUrl=stream_url, streamUrl=stream_url,
name=name, name=name,
@@ -1106,10 +1046,7 @@ class Server:
:param id: The ID for the station. :param id: The ID for the station.
""" """
return self._get_json( return self._get_json(self._make_url("deleteInternetRadioStation"), id=id,)
self._make_url('deleteInternetRadioStation'),
id=id,
)
def get_user(self, username: str) -> User: 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 :param username: The name of the user to retrieve. You can only
retrieve your own user unless you have admin privileges. 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 return result.user
def get_users(self) -> Users: def get_users(self) -> Users:
@@ -1129,7 +1066,7 @@ class Server:
folder access they have. Only users with admin privileges are allowed folder access they have. Only users with admin privileges are allowed
to call this method. 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( def create_user(
self, self,
@@ -1183,7 +1120,7 @@ class Server:
user is allowed access to. Can be a single ID or a list of IDs. user is allowed access to. Can be a single ID or a list of IDs.
""" """
return self._get_json( return self._get_json(
self._make_url('createUser'), self._make_url("createUser"),
username=username, username=username,
password=password, password=password,
email=email, email=email,
@@ -1255,7 +1192,7 @@ class Server:
user is allowed access to. Can be a single ID or a list of IDs. user is allowed access to. Can be a single ID or a list of IDs.
""" """
return self._get_json( return self._get_json(
self._make_url('updateUser'), self._make_url("updateUser"),
username=username, username=username,
password=password, password=password,
email=email, email=email,
@@ -1281,7 +1218,7 @@ class Server:
:param username: The name of the new user. :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: def change_password(self, username: str, password: str) -> Response:
""" """
@@ -1293,9 +1230,7 @@ class Server:
of hex-encoded. of hex-encoded.
""" """
return self._get_json( return self._get_json(
self._make_url('changePassword'), self._make_url("changePassword"), username=username, password=password,
username=username,
password=password,
) )
def get_bookmarks(self) -> Bookmarks: def get_bookmarks(self) -> Bookmarks:
@@ -1303,13 +1238,10 @@ class Server:
Returns all bookmarks for this user. A bookmark is a position within a Returns all bookmarks for this user. A bookmark is a position within a
certain media file. certain media file.
""" """
return self._get_json(self._make_url('getBookmarks')).bookmarks return self._get_json(self._make_url("getBookmarks")).bookmarks
def create_bookmarks( def create_bookmarks(
self, self, id: int, position: int, comment: str = None,
id: int,
position: int,
comment: str = None,
) -> Response: ) -> Response:
""" """
Creates or updates a bookmark (a position within a media file). Creates or updates a bookmark (a position within a media file).
@@ -1321,10 +1253,7 @@ class Server:
:param comment: A user-defined comment. :param comment: A user-defined comment.
""" """
return self._get_json( return self._get_json(
self._make_url('createBookmark'), self._make_url("createBookmark"), id=id, position=position, comment=comment,
id=id,
position=position,
comment=comment,
) )
def delete_bookmark(self, id: int) -> Response: 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 :param id: ID of the media file for which to delete the bookmark. Other
users' bookmarks are not affected. 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]: 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 retaining the same play queue (for instance when listening to an audio
book). book).
""" """
return self._get_json(self._make_url('getPlayQueue')).playQueue return self._get_json(self._make_url("getPlayQueue")).playQueue
def save_play_queue( def save_play_queue(
self, self, id: Union[int, List[int]], current: int = None, position: int = None,
id: Union[int, List[int]],
current: int = None,
position: int = None,
) -> Response: ) -> Response:
""" """
Saves the state of the play queue for this user. This includes the Saves the state of the play queue for this user. This includes the
@@ -1367,10 +1293,7 @@ class Server:
playing song. playing song.
""" """
return self._get_json( return self._get_json(
self._make_url('savePlayQueue'), self._make_url("savePlayQueue"), id=id, current=current, position=position,
id=id,
current=current,
position=position,
) )
def get_scan_status(self) -> ScanStatus: def get_scan_status(self) -> ScanStatus:
@@ -1378,10 +1301,10 @@ class Server:
Returns the current status for media library scanning. Takes no extra Returns the current status for media library scanning. Takes no extra
parameters. parameters.
""" """
return self._get_json(self._make_url('getScanStatus')).scanStatus return self._get_json(self._make_url("getScanStatus")).scanStatus
def start_scan(self) -> ScanStatus: def start_scan(self) -> ScanStatus:
""" """
Initiates a rescan of the media libraries. Takes no extra parameters. 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

View File

@@ -2,7 +2,8 @@ import datetime
from typing import Any, Callable, Iterable, Optional, Tuple, Union from typing import Any, Callable, Iterable, Optional, Tuple, Union
import gi 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 gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
@@ -16,12 +17,12 @@ Album = Union[Child, AlbumWithSongsID3]
class AlbumsPanel(Gtk.Box): class AlbumsPanel(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -37,18 +38,18 @@ class AlbumsPanel(Gtk.Box):
actionbar = Gtk.ActionBar() actionbar = Gtk.ActionBar()
# Sort by # Sort by
actionbar.add(Gtk.Label(label='Sort')) actionbar.add(Gtk.Label(label="Sort"))
self.sort_type_combo = self.make_combobox( self.sort_type_combo = self.make_combobox(
( (
('random', 'randomly'), ("random", "randomly"),
('byGenre', 'by genre'), ("byGenre", "by genre"),
('newest', 'by most recently added'), ("newest", "by most recently added"),
('highest', 'by highest rated'), ("highest", "by highest rated"),
('frequent', 'by most played'), ("frequent", "by most played"),
('recent', 'by most recently played'), ("recent", "by most recently played"),
('alphabetical', 'alphabetically'), ("alphabetical", "alphabetically"),
('starred', 'by starred only'), ("starred", "by starred only"),
('byYear', 'by year'), ("byYear", "by year"),
), ),
self.on_type_combo_changed, self.on_type_combo_changed,
) )
@@ -56,10 +57,7 @@ class AlbumsPanel(Gtk.Box):
# Alphabetically how? # Alphabetically how?
self.alphabetical_type_combo = self.make_combobox( 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, self.on_alphabetical_type_change,
) )
actionbar.pack_start(self.alphabetical_type_combo) actionbar.pack_start(self.alphabetical_type_combo)
@@ -70,23 +68,20 @@ class AlbumsPanel(Gtk.Box):
next_decade = datetime.datetime.now().year + 10 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) actionbar.pack_start(self.from_year_label)
self.from_year_spin_button = Gtk.SpinButton.new_with_range( self.from_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
0, next_decade, 1) self.from_year_spin_button.connect("value-changed", self.on_year_changed)
self.from_year_spin_button.connect(
'value-changed', self.on_year_changed)
actionbar.pack_start(self.from_year_spin_button) 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) actionbar.pack_start(self.to_year_label)
self.to_year_spin_button = Gtk.SpinButton.new_with_range( self.to_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
0, next_decade, 1) self.to_year_spin_button.connect("value-changed", self.on_year_changed)
self.to_year_spin_button.connect('value-changed', self.on_year_changed)
actionbar.pack_start(self.to_year_spin_button) actionbar.pack_start(self.to_year_spin_button)
refresh = IconButton('view-refresh-symbolic', 'Refresh list of albums') refresh = IconButton("view-refresh-symbolic", "Refresh list of albums")
refresh.connect('clicked', self.on_refresh_clicked) refresh.connect("clicked", self.on_refresh_clicked)
actionbar.pack_end(refresh) actionbar.pack_end(refresh)
self.add(actionbar) self.add(actionbar)
@@ -94,17 +89,16 @@ class AlbumsPanel(Gtk.Box):
scrolled_window = Gtk.ScrolledWindow() scrolled_window = Gtk.ScrolledWindow()
self.grid = AlbumsGrid() self.grid = AlbumsGrid()
self.grid.connect( self.grid.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
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) scrolled_window.add(self.grid)
self.add(scrolled_window) self.add(scrolled_window)
def make_combobox( def make_combobox(
self, self,
items: Iterable[Tuple[str, str]], items: Iterable[Tuple[str, str]],
on_change: Callable[['AlbumsPanel', Gtk.ComboBox], None], on_change: Callable[["AlbumsPanel", Gtk.ComboBox], None],
) -> Gtk.ComboBox: ) -> Gtk.ComboBox:
store = Gtk.ListStore(str, str) store = Gtk.ListStore(str, str)
for item in items: for item in items:
@@ -112,53 +106,46 @@ class AlbumsPanel(Gtk.Box):
combo = Gtk.ComboBox.new_with_model(store) combo = Gtk.ComboBox.new_with_model(store)
combo.set_id_column(0) combo.set_id_column(0)
combo.connect('changed', on_change) combo.connect("changed", on_change)
renderer_text = Gtk.CellRendererText() renderer_text = Gtk.CellRendererText()
combo.pack_start(renderer_text, True) combo.pack_start(renderer_text, True)
combo.add_attribute(renderer_text, 'text', 1) combo.add_attribute(renderer_text, "text", 1)
return combo return combo
def populate_genre_combo( def populate_genre_combo(
self, self, app_config: AppConfiguration, force: bool = False,
app_config: AppConfiguration,
force: bool = False,
): ):
if not CacheManager.ready(): if not CacheManager.ready():
return return
def get_genres_done(f: CacheManager.Result): def get_genres_done(f: CacheManager.Result):
try: try:
new_store = [ new_store = [(genre.value, genre.value) for genre in (f.result() or [])]
(genre.value, genre.value) for genre in (f.result() or [])
]
util.diff_song_store(self.genre_combo.get_model(), new_store) util.diff_song_store(self.genre_combo.get_model(), new_store)
current_genre_id = self.get_id(self.genre_combo) current_genre_id = self.get_id(self.genre_combo)
if current_genre_id != app_config.state.current_album_genre: if current_genre_id != app_config.state.current_album_genre:
self.genre_combo.set_active_id( self.genre_combo.set_active_id(app_config.state.current_album_genre)
app_config.state.current_album_genre)
finally: finally:
self.updating_query = False self.updating_query = False
# Never force. We invalidate the cache ourselves (force is used when # Never force. We invalidate the cache ourselves (force is used when
# sort params change). # sort params change).
genres_future = CacheManager.get_genres(force=False) genres_future = CacheManager.get_genres(force=False)
genres_future.add_done_callback( genres_future.add_done_callback(lambda f: GLib.idle_add(get_genres_done, f))
lambda f: GLib.idle_add(get_genres_done, f))
def update(self, app_config: AppConfiguration, force: bool = False): def update(self, app_config: AppConfiguration, force: bool = False):
self.updating_query = True self.updating_query = True
self.sort_type_combo.set_active_id(app_config.state.current_album_sort) self.sort_type_combo.set_active_id(app_config.state.current_album_sort)
self.alphabetical_type_combo.set_active_id( self.alphabetical_type_combo.set_active_id(
app_config.state.current_album_alphabetical_sort) app_config.state.current_album_alphabetical_sort
self.from_year_spin_button.set_value( )
app_config.state.current_album_from_year) self.from_year_spin_button.set_value(app_config.state.current_album_from_year)
self.to_year_spin_button.set_value( self.to_year_spin_button.set_value(app_config.state.current_album_to_year)
app_config.state.current_album_to_year)
self.populate_genre_combo(app_config, force=force) self.populate_genre_combo(app_config, force=force)
# Show/hide the combo boxes. # Show/hide the combo boxes.
@@ -169,10 +156,10 @@ class AlbumsPanel(Gtk.Box):
else: else:
element.hide() element.hide()
show_if('alphabetical', self.alphabetical_type_combo) show_if("alphabetical", self.alphabetical_type_combo)
show_if('byGenre', self.genre_combo) show_if("byGenre", self.genre_combo)
show_if('byYear', self.from_year_label, self.from_year_spin_button) 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("byYear", self.to_year_label, self.to_year_spin_button)
self.grid.update(self.grid_order_token, app_config, force=force) self.grid.update(self.grid_order_token, app_config, force=force)
@@ -183,24 +170,23 @@ class AlbumsPanel(Gtk.Box):
return None return None
def on_refresh_clicked(self, button: Any): 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): def on_type_combo_changed(self, combo: Gtk.ComboBox):
new_active_sort = self.get_id(combo) new_active_sort = self.get_id(combo)
self.grid_order_token = self.grid.update_params(type_=new_active_sort) self.grid_order_token = self.grid.update_params(type_=new_active_sort)
self.emit_if_not_updating( self.emit_if_not_updating(
'refresh-window', "refresh-window", {"current_album_sort": new_active_sort}, False,
{'current_album_sort': new_active_sort},
False,
) )
def on_alphabetical_type_change(self, combo: Gtk.ComboBox): def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
new_active_alphabetical_sort = self.get_id(combo) new_active_alphabetical_sort = self.get_id(combo)
self.grid_order_token = self.grid.update_params( 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( self.emit_if_not_updating(
'refresh-window', "refresh-window",
{'current_album_alphabetical_sort': new_active_alphabetical_sort}, {"current_album_alphabetical_sort": new_active_alphabetical_sort},
False, False,
) )
@@ -208,9 +194,7 @@ class AlbumsPanel(Gtk.Box):
new_active_genre = self.get_id(combo) new_active_genre = self.get_id(combo)
self.grid_order_token = self.grid.update_params(genre=new_active_genre) self.grid_order_token = self.grid.update_params(genre=new_active_genre)
self.emit_if_not_updating( self.emit_if_not_updating(
'refresh-window', "refresh-window", {"current_album_genre": new_active_genre}, True,
{'current_album_genre': new_active_genre},
True,
) )
def on_year_changed(self, entry: Gtk.SpinButton) -> bool: def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
@@ -219,25 +203,19 @@ class AlbumsPanel(Gtk.Box):
if self.to_year_spin_button == entry: if self.to_year_spin_button == entry:
self.grid_order_token = self.grid.update_params(to_year=year) self.grid_order_token = self.grid.update_params(to_year=year)
self.emit_if_not_updating( self.emit_if_not_updating(
'refresh-window', "refresh-window", {"current_album_to_year": year}, True,
{'current_album_to_year': year},
True,
) )
else: else:
self.grid_order_token = self.grid.update_params(from_year=year) self.grid_order_token = self.grid.update_params(from_year=year)
self.emit_if_not_updating( self.emit_if_not_updating(
'refresh-window', "refresh-window", {"current_album_from_year": year}, True,
{'current_album_from_year': year},
True,
) )
return False return False
def on_grid_cover_clicked(self, grid: Any, id: str): def on_grid_cover_clicked(self, grid: Any, id: str):
self.emit( self.emit(
'refresh-window', "refresh-window", {"selected_album_id": id}, False,
{'selected_album_id': id},
False,
) )
def emit_if_not_updating(self, *args): def emit_if_not_updating(self, *args):
@@ -248,23 +226,20 @@ class AlbumsPanel(Gtk.Box):
class AlbumsGrid(Gtk.Overlay): class AlbumsGrid(Gtk.Overlay):
"""Defines the albums panel.""" """Defines the albums panel."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'cover-clicked': ( "cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, ),
),
} }
type_: str = 'random' type_: str = "random"
alphabetical_type: str = 'name' alphabetical_type: str = "name"
from_year: int = 2010 from_year: int = 2010
to_year: int = 2020 to_year: int = 2020
genre: str = 'Rock' genre: str = "Rock"
latest_applied_order_ratchet: int = 0 latest_applied_order_ratchet: int = 0
order_ratchet: int = 0 order_ratchet: int = 0
@@ -283,7 +258,7 @@ class AlbumsGrid(Gtk.Overlay):
return self.album.id return self.album.id
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<AlbumsGrid.AlbumModel {self.album}>' return f"<AlbumsGrid.AlbumModel {self.album}>"
def update_params( def update_params(
self, self,
@@ -294,9 +269,13 @@ class AlbumsGrid(Gtk.Overlay):
genre: str = None, genre: str = None,
) -> int: ) -> int:
# If there's a diff, increase the ratchet. # If there's a diff, increase the ratchet.
if (self.type_ != type_ or self.alphabetical_type != alphabetical_type if (
or self.from_year != from_year or self.to_year != to_year self.type_ != type_
or self.genre != genre): 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.order_ratchet += 1
self.type_ = type_ or self.type_ self.type_ = type_ or self.type_
self.alphabetical_type = alphabetical_type or self.alphabetical_type self.alphabetical_type = alphabetical_type or self.alphabetical_type
@@ -328,8 +307,8 @@ class AlbumsGrid(Gtk.Overlay):
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.SINGLE, selection_mode=Gtk.SelectionMode.SINGLE,
) )
self.grid_top.connect('child-activated', self.on_child_activated) self.grid_top.connect("child-activated", self.on_child_activated)
self.grid_top.connect('size-allocate', self.on_grid_resize) self.grid_top.connect("size-allocate", self.on_grid_resize)
self.list_store_top = Gio.ListStore() self.list_store_top = Gio.ListStore()
self.grid_top.bind_model(self.list_store_top, self.create_widget) self.grid_top.bind_model(self.list_store_top, self.create_widget)
@@ -357,7 +336,7 @@ class AlbumsGrid(Gtk.Overlay):
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.SINGLE, 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.list_store_bottom = Gio.ListStore()
self.grid_bottom.bind_model(self.list_store_bottom, self.create_widget) 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.add(scrolled_window)
self.spinner = Gtk.Spinner( self.spinner = Gtk.Spinner(
name='grid-spinner', name="grid-spinner",
active=True, active=True,
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
@@ -376,10 +355,7 @@ class AlbumsGrid(Gtk.Overlay):
self.add_overlay(self.spinner) self.add_overlay(self.spinner)
def update( def update(
self, self, order_token: int, app_config: AppConfiguration, force: bool = False,
order_token: int,
app_config: AppConfiguration,
force: bool = False,
): ):
if order_token < self.latest_applied_order_ratchet: if order_token < self.latest_applied_order_ratchet:
return return
@@ -397,16 +373,13 @@ class AlbumsGrid(Gtk.Overlay):
# Update the detail panel. # Update the detail panel.
children = self.detail_box_inner.get_children() 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) children[0].update(force=force)
error_dialog = None error_dialog = None
def update_grid( def update_grid(
self, self, order_token: int, force: bool = False, selected_id: str = None,
order_token: int,
force: bool = False,
selected_id: str = None,
): ):
if not CacheManager.ready(): if not CacheManager.ready():
self.spinner.hide() self.spinner.hide()
@@ -414,11 +387,8 @@ class AlbumsGrid(Gtk.Overlay):
# Calculate the type. # Calculate the type.
type_ = self.type_ type_ = self.type_
if self.type_ == 'alphabetical': if self.type_ == "alphabetical":
type_ += { type_ += {"name": "ByName", "artist": "ByArtist"}[self.alphabetical_type]
'name': 'ByName',
'artist': 'ByArtist',
}[self.alphabetical_type]
def do_update(f: CacheManager.Result): def do_update(f: CacheManager.Result):
try: try:
@@ -431,11 +401,12 @@ class AlbumsGrid(Gtk.Overlay):
transient_for=self.get_toplevel(), transient_for=self.get_toplevel(),
message_type=Gtk.MessageType.ERROR, message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK, buttons=Gtk.ButtonsType.OK,
text='Failed to retrieve albums', text="Failed to retrieve albums",
) )
self.error_dialog.format_secondary_markup( self.error_dialog.format_secondary_markup(
f'Getting albums by {type_} failed due to the following' f"Getting albums by {type_} failed due to the following"
f' error\n\n{e}') f" error\n\n{e}"
)
self.error_dialog.run() self.error_dialog.run()
self.error_dialog.destroy() self.error_dialog.destroy()
self.error_dialog = None self.error_dialog = None
@@ -447,8 +418,8 @@ class AlbumsGrid(Gtk.Overlay):
return return
should_reload = ( should_reload = (
force force or self.latest_applied_order_ratchet < self.order_ratchet
or self.latest_applied_order_ratchet < self.order_ratchet) )
self.latest_applied_order_ratchet = self.order_ratchet self.latest_applied_order_ratchet = self.order_ratchet
self.list_store.remove_all() self.list_store.remove_all()
@@ -484,13 +455,14 @@ class AlbumsGrid(Gtk.Overlay):
# ========================================================================= # =========================================================================
def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget): def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget):
click_top = flowbox == self.grid_top click_top = flowbox == self.grid_top
selected_index = ( selected_index = child.get_index() + (
child.get_index() + (0 if click_top else len(self.list_store_top))) 0 if click_top else len(self.list_store_top)
)
if click_top and selected_index == self.current_selection: if click_top and selected_index == self.current_selection:
self.emit('cover-clicked', None) self.emit("cover-clicked", None)
else: 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): def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle):
# TODO (#124): this doesn't work with themes that add extra padding. # 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: if new_items_per_row != self.items_per_row:
self.items_per_row = min((rect.width // 230), 7) self.items_per_row = min((rect.width // 230), 7)
self.detail_box_inner.set_size_request( self.detail_box_inner.set_size_request(
self.items_per_row * 230 - 10, self.items_per_row * 230 - 10, -1,
-1,
) )
self.reflow_grids() self.reflow_grids()
# Helper Methods # 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) widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Cover art image # Cover art image
artwork = SpinnerImage( artwork = SpinnerImage(
loading=False, loading=False,
image_name='grid-artwork', image_name="grid-artwork",
spinner_name='grid-artwork-spinner', spinner_name="grid-artwork-spinner",
image_size=200, image_size=200,
) )
widget_box.pack_start(artwork, False, False, 0) widget_box.pack_start(artwork, False, False, 0)
@@ -532,16 +503,16 @@ class AlbumsGrid(Gtk.Overlay):
# Header for the widget # Header for the widget
header_text = ( header_text = (
item.album.title item.album.title if isinstance(item.album, Child) else item.album.name
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) widget_box.pack_start(header_label, False, False, 0)
# Extra info for the widget # Extra info for the widget
info_text = util.dot_join(item.album.artist, item.album.year) info_text = util.dot_join(item.album.artist, item.album.year)
if info_text: 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) widget_box.pack_start(info_label, False, False, 0)
# Download the cover art. # Download the cover art.
@@ -553,26 +524,24 @@ class AlbumsGrid(Gtk.Overlay):
artwork.set_loading(True) artwork.set_loading(True)
cover_art_filename_future = CacheManager.get_cover_art_filename( cover_art_filename_future = CacheManager.get_cover_art_filename(
item.album.coverArt, item.album.coverArt, before_download=lambda: GLib.idle_add(start_loading),
before_download=lambda: GLib.idle_add(start_loading),
) )
cover_art_filename_future.add_done_callback( 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() widget_box.show_all()
return widget_box return widget_box
def reflow_grids( def reflow_grids(
self, self, force_reload_from_master: bool = False, selection_changed: bool = False,
force_reload_from_master: bool = False,
selection_changed: bool = False,
): ):
# Determine where the cuttoff is between the top and bottom grids. # Determine where the cuttoff is between the top and bottom grids.
entries_before_fold = len(self.list_store) entries_before_fold = len(self.list_store)
if self.current_selection is not None and self.items_per_row: if self.current_selection is not None and self.items_per_row:
entries_before_fold = ( entries_before_fold = (
((self.current_selection // self.items_per_row) + 1) (self.current_selection // self.items_per_row) + 1
* self.items_per_row) ) * self.items_per_row
if force_reload_from_master: if force_reload_from_master:
# Just remove everything and re-add all of the items. # Just remove everything and re-add all of the items.
@@ -606,8 +575,7 @@ class AlbumsGrid(Gtk.Overlay):
del self.list_store_top[-1] del self.list_store_top[-1]
if self.current_selection is not None: if self.current_selection is not None:
to_select = self.grid_top.get_child_at_index( to_select = self.grid_top.get_child_at_index(self.current_selection)
self.current_selection)
if not to_select: if not to_select:
return return
self.grid_top.select_child(to_select) self.grid_top.select_child(to_select)
@@ -621,10 +589,9 @@ class AlbumsGrid(Gtk.Overlay):
model = self.list_store[self.current_selection] model = self.list_store[self.current_selection]
detail_element = AlbumWithSongs(model.album, cover_art_size=300) detail_element = AlbumWithSongs(model.album, cover_art_size=300)
detail_element.connect( detail_element.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
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_inner.pack_start(detail_element, True, True, 0)
self.detail_box.show_all() self.detail_box.show_all()

View File

@@ -2,7 +2,8 @@ from random import randint
from typing import Any, cast, List, Union from typing import Any, cast, List, Union
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gio, GLib, GObject, Gtk, Pango from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
@@ -22,12 +23,12 @@ class ArtistsPanel(Gtk.Paned):
"""Defines the arist panel.""" """Defines the arist panel."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -42,8 +43,7 @@ class ArtistsPanel(Gtk.Paned):
self.artist_detail_panel = ArtistDetailPanel() self.artist_detail_panel = ArtistDetailPanel()
self.artist_detail_panel.connect( self.artist_detail_panel.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.pack2(self.artist_detail_panel, True, False) self.pack2(self.artist_detail_panel, True, False)
@@ -70,16 +70,15 @@ class ArtistList(Gtk.Box):
list_actions = Gtk.ActionBar() list_actions = Gtk.ActionBar()
refresh = IconButton( refresh = IconButton("view-refresh-symbolic", "Refresh list of artists")
'view-refresh-symbolic', 'Refresh list of artists') refresh.connect("clicked", lambda *a: self.update(force=True))
refresh.connect('clicked', lambda *a: self.update(force=True))
list_actions.pack_end(refresh) list_actions.pack_end(refresh)
self.add(list_actions) self.add(list_actions)
self.loading_indicator = Gtk.ListBox() self.loading_indicator = Gtk.ListBox()
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) 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) spinner_row.add(spinner)
self.loading_indicator.add(spinner_row) self.loading_indicator.add(spinner_row)
self.pack_start(self.loading_indicator, False, False, 0) 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) list_scroll_window = Gtk.ScrolledWindow(min_content_width=250)
def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow: def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow:
label_text = [f'<b>{util.esc(model.name)}</b>'] label_text = [f"<b>{util.esc(model.name)}</b>"]
album_count = model.album_count album_count = model.album_count
if album_count: if album_count:
label_text.append( label_text.append(
'{} {}'.format( "{} {}".format(album_count, util.pluralize("album", album_count))
album_count, util.pluralize('album', album_count))) )
row = Gtk.ListBoxRow( row = Gtk.ListBoxRow(
action_name='app.go-to-artist', action_name="app.go-to-artist",
action_target=GLib.Variant('s', model.artist_id), action_target=GLib.Variant("s", model.artist_id),
) )
row.add( row.add(
Gtk.Label( Gtk.Label(
label='\n'.join(label_text), label="\n".join(label_text),
use_markup=True, use_markup=True,
margin=12, margin=12,
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
max_width_chars=30, max_width_chars=30,
)) )
)
row.show_all() row.show_all()
return row return row
self.artists_store = Gio.ListStore() 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) self.list.bind_model(self.artists_store, create_artist_row)
list_scroll_window.add(self.list) list_scroll_window.add(self.list)
@@ -124,24 +124,17 @@ class ArtistList(Gtk.Box):
on_failure=lambda self, e: self.loading_indicator.hide(), on_failure=lambda self, e: self.loading_indicator.hide(),
) )
def update( def update(
self, self, artists: List[ArtistID3], app_config: AppConfiguration, **kwargs,
artists: List[ArtistID3],
app_config: AppConfiguration,
**kwargs,
): ):
new_store = [] new_store = []
selected_idx = None selected_idx = None
for i, artist in enumerate(artists): for i, artist in enumerate(artists):
if (app_config.state if app_config.state and app_config.state.selected_artist_id == artist.id:
and app_config.state.selected_artist_id == artist.id):
selected_idx = i selected_idx = i
new_store.append( new_store.append(
_ArtistModel( _ArtistModel(artist.id, artist.name, artist.get("albumCount", ""),)
artist.id, )
artist.name,
artist.get('albumCount', ''),
))
util.diff_model_store(self.artists_store, new_store) util.diff_model_store(self.artists_store, new_store)
@@ -157,7 +150,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
"""Defines the artists list.""" """Defines the artists list."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
@@ -167,7 +160,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
update_order_token = 0 update_order_token = 0
def __init__(self, *args, **kwargs): 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.albums: Union[List[AlbumID3], List[Child]] = []
self.artist_id = None self.artist_id = None
@@ -178,8 +171,8 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
self.artist_artwork = SpinnerImage( self.artist_artwork = SpinnerImage(
loading=False, loading=False,
image_name='artist-album-artwork', image_name="artist-album-artwork",
spinner_name='artist-artwork-spinner', spinner_name="artist-artwork-spinner",
image_size=300, image_size=300,
) )
self.big_info_panel.pack_start(self.artist_artwork, False, False, 0) 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 # Action buttons (note we are packing end here, so we have to put them
# in right-to-left). # in right-to-left).
self.artist_action_buttons = Gtk.Box( self.artist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
view_refresh_button = IconButton( view_refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
'view-refresh-symbolic', 'Refresh artist info') view_refresh_button.connect("clicked", self.on_view_refresh_click)
view_refresh_button.connect('clicked', self.on_view_refresh_click) self.artist_action_buttons.pack_end(view_refresh_button, False, False, 5)
self.artist_action_buttons.pack_end(
view_refresh_button, False, False, 5)
download_all_btn = IconButton( download_all_btn = IconButton(
'folder-download-symbolic', 'Download all songs by this artist') "folder-download-symbolic", "Download all songs by this artist"
download_all_btn.connect('clicked', self.on_download_all_click) )
download_all_btn.connect("clicked", self.on_download_all_click)
self.artist_action_buttons.pack_end(download_all_btn, False, False, 5) self.artist_action_buttons.pack_end(download_all_btn, False, False, 5)
artist_details_box.pack_start( artist_details_box.pack_start(self.artist_action_buttons, False, False, 5)
self.artist_action_buttons, False, False, 5)
artist_details_box.pack_start(Gtk.Box(), True, False, 0) 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) 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) artist_details_box.add(self.artist_name)
self.artist_bio = self.make_label( 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) self.artist_bio.set_line_wrap(True)
artist_details_box.add(self.artist_bio) artist_details_box.add(self.artist_bio)
self.similar_artists_scrolledwindow = Gtk.ScrolledWindow() self.similar_artists_scrolledwindow = Gtk.ScrolledWindow()
similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 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) similar_artists_box.add(self.similar_artists_label)
self.similar_artists_button_box = Gtk.Box( self.similar_artists_button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL) orientation=Gtk.Orientation.HORIZONTAL
)
similar_artists_box.add(self.similar_artists_button_box) similar_artists_box.add(self.similar_artists_button_box)
self.similar_artists_scrolledwindow.add(similar_artists_box) self.similar_artists_scrolledwindow.add(similar_artists_box)
artist_details_box.add(self.similar_artists_scrolledwindow) 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) artist_details_box.add(self.artist_stats)
self.play_shuffle_buttons = Gtk.Box( self.play_shuffle_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, orientation=Gtk.Orientation.HORIZONTAL,
name='playlist-play-shuffle-buttons', name="playlist-play-shuffle-buttons",
) )
play_button = IconButton( play_button = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic", label="Play All", relief=True,
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) self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
shuffle_button = IconButton( shuffle_button = IconButton(
'media-playlist-shuffle-symbolic', "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True,
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) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
artist_details_box.add(self.play_shuffle_buttons) artist_details_box.add(self.play_shuffle_buttons)
@@ -263,8 +251,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
self.albums_list = AlbumsListWithSongs() self.albums_list = AlbumsListWithSongs()
self.albums_list.connect( self.albums_list.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
artist_info_box.pack_start(self.albums_list, True, True, 0) 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 self.artist_id = app_config.state.selected_artist_id
if app_config.state.selected_artist_id is None: if app_config.state.selected_artist_id is None:
self.artist_action_buttons.hide() self.artist_action_buttons.hide()
self.artist_indicator.set_text('') self.artist_indicator.set_text("")
self.artist_name.set_markup('') self.artist_name.set_markup("")
self.artist_stats.set_markup('') self.artist_stats.set_markup("")
self.artist_bio.set_markup('') self.artist_bio.set_markup("")
self.similar_artists_scrolledwindow.hide() self.similar_artists_scrolledwindow.hide()
self.play_shuffle_buttons.hide() self.play_shuffle_buttons.hide()
@@ -310,27 +297,21 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return return
self.artist_indicator.set_text('ARTIST') self.artist_indicator.set_text("ARTIST")
self.artist_name.set_markup(util.esc(f'<b>{artist.name}</b>')) self.artist_name.set_markup(util.esc(f"<b>{artist.name}</b>"))
self.artist_stats.set_markup(self.format_stats(artist)) self.artist_stats.set_markup(self.format_stats(artist))
self.update_artist_info( self.update_artist_info(
artist.id, artist.id, force=force, order_token=order_token,
force=force,
order_token=order_token,
) )
self.update_artist_artwork( self.update_artist_artwork(
artist, artist, force=force, order_token=order_token,
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) self.albums_list.update(artist)
@util.async_callback( @util.async_callback(lambda *a, **k: CacheManager.get_artist_info(*a, **k),)
lambda *a, **k: CacheManager.get_artist_info(*a, **k),
)
def update_artist_info( def update_artist_info(
self, self,
artist_info: ArtistInfo2, artist_info: ArtistInfo2,
@@ -341,11 +322,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return 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() self.play_shuffle_buttons.show_all()
if len(artist_info.similarArtist or []) > 0: if len(artist_info.similarArtist or []) > 0:
self.similar_artists_label.set_markup('<b>Similar Artists:</b> ') self.similar_artists_label.set_markup("<b>Similar Artists:</b> ")
for c in self.similar_artists_button_box.get_children(): for c in self.similar_artists_button_box.get_children():
self.similar_artists_button_box.remove(c) self.similar_artists_button_box.remove(c)
@@ -353,10 +334,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
self.similar_artists_button_box.add( self.similar_artists_button_box.add(
Gtk.LinkButton( Gtk.LinkButton(
label=artist.name, label=artist.name,
name='similar-artist-button', name="similar-artist-button",
action_name='app.go-to-artist', action_name="app.go-to-artist",
action_target=GLib.Variant('s', artist.id), action_target=GLib.Variant("s", artist.id),
)) )
)
self.similar_artists_scrolledwindow.show_all() self.similar_artists_scrolledwindow.show_all()
else: else:
self.similar_artists_scrolledwindow.hide() self.similar_artists_scrolledwindow.hide()
@@ -383,41 +365,33 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
# ========================================================================= # =========================================================================
def on_view_refresh_click(self, *args): def on_view_refresh_click(self, *args):
self.update_artist_view( self.update_artist_view(
self.artist_id, self.artist_id, force=True, order_token=self.update_order_token,
force=True,
order_token=self.update_order_token,
) )
def on_download_all_click(self, btn: Any): def on_download_all_click(self, btn: Any):
CacheManager.batch_download_songs( CacheManager.batch_download_songs(
self.get_artist_song_ids(), self.get_artist_song_ids(),
before_download=lambda: self.update_artist_view( before_download=lambda: self.update_artist_view(
self.artist_id, self.artist_id, order_token=self.update_order_token,
order_token=self.update_order_token,
), ),
on_song_download_complete=lambda i: self.update_artist_view( on_song_download_complete=lambda i: self.update_artist_view(
self.artist_id, self.artist_id, order_token=self.update_order_token,
order_token=self.update_order_token,
), ),
) )
def on_play_all_clicked(self, btn: Any): def on_play_all_clicked(self, btn: Any):
songs = self.get_artist_song_ids() songs = self.get_artist_song_ids()
self.emit( self.emit(
'song-clicked', "song-clicked", 0, songs, {"force_shuffle_state": False},
0,
songs,
{'force_shuffle_state': False},
) )
def on_shuffle_all_button(self, btn: Any): def on_shuffle_all_button(self, btn: Any):
songs = self.get_artist_song_ids() songs = self.get_artist_song_ids()
self.emit( self.emit(
'song-clicked', "song-clicked",
randint(0, randint(0, len(songs) - 1),
len(songs) - 1),
songs, songs,
{'force_shuffle_state': True}, {"force_shuffle_state": True},
) )
# Helper Methods # Helper Methods
@@ -431,27 +405,18 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
self.albums_list.spinner.hide() self.albums_list.spinner.hide()
self.artist_artwork.set_loading(False) self.artist_artwork.set_loading(False)
def make_label( def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label:
self,
text: str = None,
name: str = None,
**params,
) -> Gtk.Label:
return Gtk.Label( return Gtk.Label(
label=text, label=text, name=name, halign=Gtk.Align.START, xalign=0, **params,
name=name,
halign=Gtk.Align.START,
xalign=0,
**params,
) )
def format_stats(self, artist: ArtistWithAlbumsID3) -> str: 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) song_count = sum(a.songCount for a in artist.album)
duration = sum(a.duration for a in artist.album) duration = sum(a.duration for a in artist.album)
return util.dot_join( return util.dot_join(
'{} {}'.format(album_count, util.pluralize('album', album_count)), "{} {}".format(album_count, util.pluralize("album", album_count)),
'{} {}'.format(song_count, util.pluralize('song', song_count)), "{} {}".format(song_count, util.pluralize("song", song_count)),
util.format_sequence_duration(duration), util.format_sequence_duration(duration),
) )
@@ -459,7 +424,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
songs = [] songs = []
for album in CacheManager.get_artist(self.artist_id).result().album: for album in CacheManager.get_artist(self.artist_id).result().album:
album_songs = CacheManager.get_album(album.id).result() 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) songs.append(song.id)
return songs return songs
@@ -467,7 +432,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
class AlbumsListWithSongs(Gtk.Overlay): class AlbumsListWithSongs(Gtk.Overlay):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
@@ -480,7 +445,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.add(self.box) self.add(self.box)
self.spinner = Gtk.Spinner( self.spinner = Gtk.Spinner(
name='albumslist-with-songs-spinner', name="albumslist-with-songs-spinner",
active=False, active=False,
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
@@ -499,7 +464,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.spinner.hide() self.spinner.hide()
return return
new_albums = artist.get('album', artist.get('child', [])) new_albums = artist.get("album", artist.get("child", []))
if self.albums == new_albums: if self.albums == new_albums:
# No need to do anything. # No need to do anything.
@@ -513,10 +478,9 @@ class AlbumsListWithSongs(Gtk.Overlay):
for album in self.albums: for album in self.albums:
album_with_songs = AlbumWithSongs(album, show_artist_name=False) album_with_songs = AlbumWithSongs(album, show_artist_name=False)
album_with_songs.connect( album_with_songs.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
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() album_with_songs.show_all()
self.box.add(album_with_songs) self.box.add(album_with_songs)

View File

@@ -1,7 +1,8 @@
from typing import Any, List, Optional, Tuple, Type, Union from typing import Any, List, Optional, Tuple, Type, Union
import gi 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 gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
@@ -15,12 +16,12 @@ class BrowsePanel(Gtk.Overlay):
"""Defines the arist panel.""" """Defines the arist panel."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -36,19 +37,17 @@ class BrowsePanel(Gtk.Overlay):
self.root_directory_listing = ListAndDrilldown(IndexList) self.root_directory_listing = ListAndDrilldown(IndexList)
self.root_directory_listing.connect( self.root_directory_listing.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.root_directory_listing.connect( self.root_directory_listing.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
scrolled_window.add(self.root_directory_listing) scrolled_window.add(self.root_directory_listing)
self.add(scrolled_window) self.add(scrolled_window)
self.spinner = Gtk.Spinner( self.spinner = Gtk.Spinner(
name='browse-spinner', name="browse-spinner",
active=True, active=True,
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
@@ -66,9 +65,7 @@ class BrowsePanel(Gtk.Overlay):
return return
self.root_directory_listing.update( self.root_directory_listing.update(
id_stack, id_stack, app_config=app_config, force=force,
app_config=app_config,
force=force,
) )
self.spinner.hide() self.spinner.hide()
@@ -81,30 +78,25 @@ class BrowsePanel(Gtk.Overlay):
current_dir_id = app_config.state.selected_browse_element_id current_dir_id = app_config.state.selected_browse_element_id
while directory is None or directory.parent is not None: while directory is None or directory.parent is not None:
directory = CacheManager.get_music_directory( directory = CacheManager.get_music_directory(
current_dir_id, current_dir_id, before_download=self.spinner.show,
before_download=self.spinner.show,
).result() ).result()
id_stack.append(directory.id) id_stack.append(directory.id)
current_dir_id = directory.parent current_dir_id = directory.parent
return id_stack, update_order_token return id_stack, update_order_token
path_fut = CacheManager.create_future( path_fut = CacheManager.create_future(calculate_path, self.update_order_token,)
calculate_path, path_fut.add_done_callback(lambda f: GLib.idle_add(do_update, *f.result()))
self.update_order_token,
)
path_fut.add_done_callback(
lambda f: GLib.idle_add(do_update, *f.result()))
class ListAndDrilldown(Gtk.Paned): class ListAndDrilldown(Gtk.Paned):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -118,12 +110,10 @@ class ListAndDrilldown(Gtk.Paned):
self.list = list_type() self.list = list_type()
self.list.connect( self.list.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.list.connect( self.list.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
self.pack1(self.list, False, False) self.pack1(self.list, False, False)
@@ -149,10 +139,7 @@ class ListAndDrilldown(Gtk.Paned):
# away the drilldown. # away the drilldown.
if isinstance(self.drilldown, ListAndDrilldown): if isinstance(self.drilldown, ListAndDrilldown):
self.drilldown.update( self.drilldown.update(
id_stack[:-1], id_stack[:-1], app_config, force=force, directory_id=id_stack[-1],
app_config,
force=force,
directory_id=id_stack[-1],
) )
return return
self.id_stack = id_stack self.id_stack = id_stack
@@ -161,18 +148,13 @@ class ListAndDrilldown(Gtk.Paned):
self.remove(self.drilldown) self.remove(self.drilldown)
self.drilldown = ListAndDrilldown(MusicDirectoryList) self.drilldown = ListAndDrilldown(MusicDirectoryList)
self.drilldown.connect( self.drilldown.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.drilldown.connect( self.drilldown.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
self.drilldown.update( self.drilldown.update(
id_stack[:-1], id_stack[:-1], app_config, force=force, directory_id=id_stack[-1],
app_config,
force=force,
directory_id=id_stack[-1],
) )
self.drilldown.show_all() self.drilldown.show_all()
self.pack2(self.drilldown, True, False) self.pack2(self.drilldown, True, False)
@@ -180,12 +162,12 @@ class ListAndDrilldown(Gtk.Paned):
class DrilldownList(Gtk.Box): class DrilldownList(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -200,24 +182,23 @@ class DrilldownList(Gtk.Box):
def __init__(self, element: Union[Child, Artist]): def __init__(self, element: Union[Child, Artist]):
GObject.GObject.__init__(self) GObject.GObject.__init__(self)
self.id = element.id self.id = element.id
self.name = ( self.name = element.name if isinstance(element, Artist) else element.title
element.name if isinstance(element, Artist) else element.title) self.is_dir = element.get("isDir", True)
self.is_dir = element.get('isDir', True)
def __init__(self): def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
list_actions = Gtk.ActionBar() list_actions = Gtk.ActionBar()
refresh = IconButton('view-refresh-symbolic', 'Refresh folder') refresh = IconButton("view-refresh-symbolic", "Refresh folder")
refresh.connect('clicked', self.on_refresh_clicked) refresh.connect("clicked", self.on_refresh_clicked)
list_actions.pack_end(refresh) list_actions.pack_end(refresh)
self.add(list_actions) self.add(list_actions)
self.loading_indicator = Gtk.ListBox() self.loading_indicator = Gtk.ListBox()
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) 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) spinner_row.add(spinner)
self.loading_indicator.add(spinner_row) self.loading_indicator.add(spinner_row)
self.pack_start(self.loading_indicator, False, False, 0) self.pack_start(self.loading_indicator, False, False, 0)
@@ -231,36 +212,32 @@ class DrilldownList(Gtk.Box):
scrollbox.add(self.list) scrollbox.add(self.list)
self.directory_song_store = Gtk.ListStore( self.directory_song_store = Gtk.ListStore(
str, # cache status str, str, str, str, # cache status # title # duration # song ID
str, # title
str, # duration
str, # song ID
) )
self.directory_song_list = Gtk.TreeView( self.directory_song_list = Gtk.TreeView(
model=self.directory_song_store, model=self.directory_song_store,
name='album-songs-list', name="album-songs-list",
headers_visible=False, headers_visible=False,
) )
self.directory_song_list.get_selection().set_mode( self.directory_song_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
Gtk.SelectionMode.MULTIPLE)
# Song status column. # Song status column.
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35) renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0) column = Gtk.TreeViewColumn("", renderer, icon_name=0)
column.set_resizable(True) column.set_resizable(True)
self.directory_song_list.append_column(column) self.directory_song_list.append_column(column)
self.directory_song_list.append_column(SongListColumn("TITLE", 1, bold=True))
self.directory_song_list.append_column( self.directory_song_list.append_column(
SongListColumn('TITLE', 1, bold=True)) SongListColumn("DURATION", 2, align=1, width=40)
self.directory_song_list.append_column( )
SongListColumn('DURATION', 2, align=1, width=40))
self.directory_song_list.connect("row-activated", self.on_song_activated)
self.directory_song_list.connect( self.directory_song_list.connect(
'row-activated', self.on_song_activated) "button-press-event", self.on_song_button_press
self.directory_song_list.connect( )
'button-press-event', self.on_song_button_press)
scrollbox.add(self.directory_song_list) scrollbox.add(self.directory_song_list)
self.scroll_window.add(scrollbox) 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): def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.directory_song_store], [m[-1] for m in self.directory_song_store],
{}, {},
) )
def on_song_button_press( def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton,) -> bool:
self,
tree: Gtk.TreeView,
event: Gdk.EventButton,
) -> bool:
if event.button == 3: # Right click if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y) clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path: if not clicked_path:
@@ -297,10 +270,8 @@ class DrilldownList(Gtk.Box):
song_ids = [self.directory_song_store[p][-1] for p in paths] song_ids = [self.directory_song_store[p][-1] for p in paths]
# Used to adjust for the header row. # Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords( bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
event.x, event.y) widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
@@ -322,23 +293,21 @@ class DrilldownList(Gtk.Box):
selected_dir_idx = None selected_dir_idx = None
for idx, el in enumerate(elements or []): for idx, el in enumerate(elements or []):
if el.get('isDir', True): if el.get("isDir", True):
new_directories_store.append( new_directories_store.append(DrilldownList.DrilldownElement(el))
DrilldownList.DrilldownElement(el))
if el.id == self.selected_id: if el.id == self.selected_id:
selected_dir_idx = idx selected_dir_idx = idx
else: else:
new_songs_store.append( new_songs_store.append(
[ [
util.get_cached_status_icon( util.get_cached_status_icon(CacheManager.get_cached_status(el)),
CacheManager.get_cached_status(el)),
util.esc(el.title), util.esc(el.title),
util.format_song_duration(el.duration), util.format_song_duration(el.duration),
el.id, el.id,
]) ]
)
util.diff_model_store( util.diff_model_store(self.drilldown_directories_store, new_directories_store)
self.drilldown_directories_store, new_directories_store)
util.diff_song_store(self.directory_song_store, new_songs_store) util.diff_song_store(self.directory_song_store, new_songs_store)
@@ -361,23 +330,22 @@ class DrilldownList(Gtk.Box):
self.loading_indicator.hide() self.loading_indicator.hide()
def create_row( def create_row(self, model: "DrilldownList.DrilldownElement") -> Gtk.ListBoxRow:
self, model: 'DrilldownList.DrilldownElement') -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow( row = Gtk.ListBoxRow(
action_name='app.browse-to', action_name="app.browse-to", action_target=GLib.Variant("s", model.id),
action_target=GLib.Variant('s', model.id),
) )
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
rowbox.add( rowbox.add(
Gtk.Label( Gtk.Label(
label=f'<b>{util.esc(model.name)}</b>', label=f"<b>{util.esc(model.name)}</b>",
use_markup=True, use_markup=True,
margin=8, margin=8,
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, 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) image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
rowbox.pack_end(image, False, False, 5) rowbox.pack_end(image, False, False, 5)
row.add(rowbox) row.add(rowbox)
@@ -398,9 +366,7 @@ class IndexList(DrilldownList):
self.update_order_token += 1 self.update_order_token += 1
self.selected_id = selected_id self.selected_id = selected_id
self.update_store( self.update_store(
force=force, force=force, app_config=app_config, order_token=self.update_order_token,
app_config=app_config,
order_token=self.update_order_token,
) )
def on_refresh_clicked(self, _: Any): def on_refresh_clicked(self, _: Any):
@@ -447,8 +413,7 @@ class MusicDirectoryList(DrilldownList):
) )
def on_refresh_clicked(self, _: Any): def on_refresh_clicked(self, _: Any):
self.update( self.update(self.selected_id, force=True, directory_id=self.directory_id)
self.selected_id, force=True, directory_id=self.directory_id)
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_music_directory(*a, **k), lambda *a, **k: CacheManager.get_music_directory(*a, **k),

View File

@@ -5,10 +5,10 @@ from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage from .spinner_image import SpinnerImage
__all__ = ( __all__ = (
'AlbumWithSongs', "AlbumWithSongs",
'EditFormDialog', "EditFormDialog",
'IconButton', "IconButton",
'IconToggleButton', "IconToggleButton",
'SongListColumn', "SongListColumn",
'SpinnerImage', "SpinnerImage",
) )

View File

@@ -2,7 +2,8 @@ from random import randint
from typing import Any, Union from typing import Any, Union
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GLib, GObject, Gtk, Pango from gi.repository import Gdk, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
@@ -16,12 +17,8 @@ from sublime.ui.common.spinner_image import SpinnerImage
class AlbumWithSongs(Gtk.Box): class AlbumWithSongs(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'song-selected': ( "song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (),),
GObject.SignalFlags.RUN_FIRST, "song-clicked": (
GObject.TYPE_NONE,
(),
),
'song-clicked': (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
@@ -40,13 +37,12 @@ class AlbumWithSongs(Gtk.Box):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
artist_artwork = SpinnerImage( artist_artwork = SpinnerImage(
loading=False, loading=False,
image_name='artist-album-list-artwork', image_name="artist-album-list-artwork",
spinner_name='artist-artwork-spinner', spinner_name="artist-artwork-spinner",
image_size=cover_art_size, image_size=cover_art_size,
) )
# Account for 10px margin on all sides with "+ 20". # Account for 10px margin on all sides with "+ 20".
artist_artwork.set_size_request( artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
cover_art_size + 20, cover_art_size + 20)
box.pack_start(artist_artwork, False, False, 0) box.pack_start(artist_artwork, False, False, 0)
box.pack_start(Gtk.Box(), True, True, 0) box.pack_start(Gtk.Box(), True, True, 0)
self.pack_start(box, False, False, 0) self.pack_start(box, False, False, 0)
@@ -56,64 +52,62 @@ class AlbumWithSongs(Gtk.Box):
artist_artwork.set_loading(False) artist_artwork.set_loading(False)
cover_art_filename_future = CacheManager.get_cover_art_filename( cover_art_filename_future = CacheManager.get_cover_art_filename(
album.coverArt, album.coverArt, before_download=lambda: artist_artwork.set_loading(True),
before_download=lambda: artist_artwork.set_loading(True),
) )
cover_art_filename_future.add_done_callback( 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_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
album_title_and_buttons = Gtk.Box( album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
# TODO (#43): deal with super long-ass titles # TODO (#43): deal with super long-ass titles
album_title_and_buttons.add( album_title_and_buttons.add(
Gtk.Label( Gtk.Label(
label=album.get('name', album.get('title')), label=album.get("name", album.get("title")),
name='artist-album-list-album-name', name="artist-album-list-album-name",
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
)) )
)
self.play_btn = IconButton( self.play_btn = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic",
'Play all songs in this album', "Play all songs in this album",
sensitive=False, 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) album_title_and_buttons.pack_start(self.play_btn, False, False, 5)
self.shuffle_btn = IconButton( self.shuffle_btn = IconButton(
'media-playlist-shuffle-symbolic', "media-playlist-shuffle-symbolic",
'Shuffle all songs in this album', "Shuffle all songs in this album",
sensitive=False, 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) album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
self.play_next_btn = IconButton( self.play_next_btn = IconButton(
'go-top-symbolic', "go-top-symbolic",
'Play all of the songs in this album next', "Play all of the songs in this album next",
action_name='app.play-next', action_name="app.play-next",
) )
album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5) album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
self.add_to_queue_btn = IconButton( self.add_to_queue_btn = IconButton(
'go-jump-symbolic', "go-jump-symbolic",
'Add all the songs in this album to the end of the play queue', "Add all the songs in this album to the end of the play queue",
action_name='app.add-to-queue', action_name="app.add-to-queue",
) )
album_title_and_buttons.pack_start( album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False, 5)
self.add_to_queue_btn, False, False, 5)
self.download_all_btn = IconButton( self.download_all_btn = IconButton(
'folder-download-symbolic', "folder-download-symbolic",
'Download all songs in this album', "Download all songs in this album",
sensitive=False, sensitive=False,
) )
self.download_all_btn.connect('clicked', self.on_download_all_click) self.download_all_btn.connect("clicked", self.on_download_all_click)
album_title_and_buttons.pack_end( album_title_and_buttons.pack_end(self.download_all_btn, False, False, 5)
self.download_all_btn, False, False, 5)
album_details.add(album_title_and_buttons) album_details.add(album_title_and_buttons)
@@ -122,30 +116,26 @@ class AlbumWithSongs(Gtk.Box):
album.year, album.year,
album.genre, album.genre,
util.format_sequence_duration(album.duration) util.format_sequence_duration(album.duration)
if album.get('duration') else None, if album.get("duration")
else None,
] ]
album_details.add( album_details.add(
Gtk.Label( Gtk.Label(
label=util.dot_join(*stats), label=util.dot_join(*stats), halign=Gtk.Align.START, margin_left=10,
halign=Gtk.Align.START, )
margin_left=10,
))
self.album_song_store = Gtk.ListStore(
str, # cache status
str, # title
str, # duration
str, # song ID
) )
self.loading_indicator = Gtk.Spinner( self.album_song_store = Gtk.ListStore(
name='album-list-song-list-spinner') 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) album_details.add(self.loading_indicator)
self.album_songs = Gtk.TreeView( self.album_songs = Gtk.TreeView(
model=self.album_song_store, model=self.album_song_store,
name='album-songs-list', name="album-songs-list",
headers_visible=False, headers_visible=False,
margin_top=15, margin_top=15,
margin_left=10, margin_left=10,
@@ -157,19 +147,18 @@ class AlbumWithSongs(Gtk.Box):
# Song status column. # Song status column.
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35) renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0) column = Gtk.TreeViewColumn("", renderer, icon_name=0)
column.set_resizable(True) column.set_resizable(True)
self.album_songs.append_column(column) self.album_songs.append_column(column)
self.album_songs.append_column(SongListColumn('TITLE', 1, bold=True)) self.album_songs.append_column(SongListColumn("TITLE", 1, bold=True))
self.album_songs.append_column( self.album_songs.append_column(SongListColumn("DURATION", 2, align=1, width=40))
SongListColumn('DURATION', 2, align=1, width=40))
self.album_songs.connect('row-activated', self.on_song_activated) self.album_songs.connect("row-activated", self.on_song_activated)
self.album_songs.connect( self.album_songs.connect("button-press-event", self.on_song_button_press)
'button-press-event', self.on_song_button_press)
self.album_songs.get_selection().connect( self.album_songs.get_selection().connect(
'changed', self.on_song_selection_change) "changed", self.on_song_selection_change
)
album_details.add(self.album_songs) album_details.add(self.album_songs)
self.pack_end(album_details, True, True, 0) self.pack_end(album_details, True, True, 0)
@@ -180,12 +169,12 @@ class AlbumWithSongs(Gtk.Box):
# ========================================================================= # =========================================================================
def on_song_selection_change(self, event: Any): def on_song_selection_change(self, event: Any):
if not self.album_songs.has_focus(): 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): def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.album_song_store], [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] song_ids = [self.album_song_store[p][-1] for p in paths]
# Used to adjust for the header row. # Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords( bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
event.x, event.y) widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
@@ -241,20 +228,16 @@ class AlbumWithSongs(Gtk.Box):
def play_btn_clicked(self, btn: Any): def play_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store] song_ids = [x[-1] for x in self.album_song_store]
self.emit( self.emit(
'song-clicked', "song-clicked", 0, song_ids, {"force_shuffle_state": False},
0,
song_ids,
{'force_shuffle_state': False},
) )
def shuffle_btn_clicked(self, btn: Any): def shuffle_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store] song_ids = [x[-1] for x in self.album_song_store]
self.emit( self.emit(
'song-clicked', "song-clicked",
randint(0, randint(0, len(self.album_song_store) - 1),
len(self.album_song_store) - 1),
song_ids, song_ids,
{'force_shuffle_state': True}, {"force_shuffle_state": True},
) )
# Helper Methods # Helper Methods
@@ -287,22 +270,20 @@ class AlbumWithSongs(Gtk.Box):
): ):
new_store = [ new_store = [
[ [
util.get_cached_status_icon( util.get_cached_status_icon(CacheManager.get_cached_status(song)),
CacheManager.get_cached_status(song)),
util.esc(song.title), util.esc(song.title),
util.format_song_duration(song.duration), util.format_song_duration(song.duration),
song.id, 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] song_ids = [song[-1] for song in new_store]
self.play_btn.set_sensitive(True) self.play_btn.set_sensitive(True)
self.shuffle_btn.set_sensitive(True) self.shuffle_btn.set_sensitive(True)
self.play_next_btn.set_action_target_value( self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
GLib.Variant('as', song_ids)) self.add_to_queue_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) self.download_all_btn.set_sensitive(True)
util.diff_song_store(self.album_song_store, new_store) util.diff_song_store(self.album_song_store, new_store)

View File

@@ -1,7 +1,8 @@
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk from gi.repository import Gtk
TextFieldDescription = Tuple[str, str, bool] TextFieldDescription = Tuple[str, str, bool]
@@ -25,25 +26,22 @@ class EditFormDialog(Gtk.Dialog):
""" """
Gets the friendly object name. Can be overridden. 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): def get_default_object(self):
return None return None
def __init__(self, parent: Any, existing_object: Any = None): def __init__(self, parent: Any, existing_object: Any = None):
editing = existing_object is not None editing = existing_object is not None
title = getattr(self, 'title', None) title = getattr(self, "title", None)
if not title: if not title:
if editing: if editing:
title = f'Edit {self.get_object_name(existing_object)}' title = f"Edit {self.get_object_name(existing_object)}"
else: else:
title = f'Create New {self.entity_name}' title = f"Create New {self.entity_name}"
Gtk.Dialog.__init__( Gtk.Dialog.__init__(
self, self, title=title, transient_for=parent, flags=0,
title=title,
transient_for=parent,
flags=0,
) )
if not existing_object: if not existing_object:
existing_object = self.get_default_object() existing_object = self.get_default_object()
@@ -55,22 +53,18 @@ class EditFormDialog(Gtk.Dialog):
content_area = self.get_content_area() content_area = self.get_content_area()
content_grid = Gtk.Grid( content_grid = Gtk.Grid(
column_spacing=10, column_spacing=10, row_spacing=5, margin_left=10, margin_right=10,
row_spacing=5,
margin_left=10,
margin_right=10,
) )
# Add the text entries to the content area. # Add the text entries to the content area.
i = 0 i = 0
for label, value_field_name, is_password in self.text_fields: 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) entry_label.set_halign(Gtk.Align.START)
content_grid.attach(entry_label, 0, i, 1, 1) content_grid.attach(entry_label, 0, i, 1, 1)
entry = Gtk.Entry( entry = Gtk.Entry(
text=getattr(existing_object, value_field_name, ''), text=getattr(existing_object, value_field_name, ""), hexpand=True,
hexpand=True,
) )
if is_password: if is_password:
entry.set_visibility(False) entry.set_visibility(False)
@@ -80,7 +74,7 @@ class EditFormDialog(Gtk.Dialog):
i += 1 i += 1
for label, value_field_name, options in self.option_fields: 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) entry_label.set_halign(Gtk.Align.START)
content_grid.attach(entry_label, 0, i, 1, 1) 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. # Add the boolean entries to the content area.
for label, value_field_name in self.boolean_fields: 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) entry_label.set_halign(Gtk.Align.START)
content_grid.attach(entry_label, 0, i, 1, 1) content_grid.attach(entry_label, 0, i, 1, 1)
# Put the checkbox in the right box. Note we have to pad here # Put the checkbox in the right box. Note we have to pad here
# since the checkboxes are smaller than the text fields. # since the checkboxes are smaller than the text fields.
checkbox = Gtk.CheckButton( 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 self.data[value_field_name] = checkbox
content_grid.attach(checkbox, 1, i, 1, 1) content_grid.attach(checkbox, 1, i, 1, 1)
i += 1 i += 1
# Add the spin button entries to the content area. # Add the spin button entries to the content area.
for (label, value_field_name, range_config, for (
default_value) in self.numeric_fields: label,
entry_label = Gtk.Label(label=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) entry_label.set_halign(Gtk.Align.START)
content_grid.attach(entry_label, 0, i, 1, 1) 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. # since the checkboxes are smaller than the text fields.
spin_button = Gtk.SpinButton.new_with_range(*range_config) spin_button = Gtk.SpinButton.new_with_range(*range_config)
spin_button.set_value( 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 self.data[value_field_name] = spin_button
content_grid.attach(spin_button, 1, i, 1, 1) content_grid.attach(spin_button, 1, i, 1, 1)
i += 1 i += 1

View File

@@ -1,7 +1,8 @@
from typing import Optional from typing import Optional
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk from gi.repository import Gtk
@@ -9,7 +10,7 @@ class IconButton(Gtk.Button):
def __init__( def __init__(
self, self,
icon_name: Optional[str], icon_name: Optional[str],
tooltip_text: str = '', tooltip_text: str = "",
relief: bool = False, relief: bool = False,
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
label: str = None, label: str = None,
@@ -18,8 +19,7 @@ class IconButton(Gtk.Button):
Gtk.Button.__init__(self, **kwargs) Gtk.Button.__init__(self, **kwargs)
self.icon_size = icon_size self.icon_size = icon_size
box = Gtk.Box( box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
orientation=Gtk.Orientation.HORIZONTAL, name='icon-button-box')
self.image = Gtk.Image() self.image = Gtk.Image()
self.image.set_from_icon_name(icon_name, self.icon_size) self.image.set_from_icon_name(icon_name, self.icon_size)
@@ -42,7 +42,7 @@ class IconToggleButton(Gtk.ToggleButton):
def __init__( def __init__(
self, self,
icon_name: Optional[str], icon_name: Optional[str],
tooltip_text: str = '', tooltip_text: str = "",
relief: bool = False, relief: bool = False,
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
label: str = None, label: str = None,
@@ -50,8 +50,7 @@ class IconToggleButton(Gtk.ToggleButton):
): ):
Gtk.ToggleButton.__init__(self, **kwargs) Gtk.ToggleButton.__init__(self, **kwargs)
self.icon_size = icon_size self.icon_size = icon_size
box = Gtk.Box( box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
orientation=Gtk.Orientation.HORIZONTAL, name='icon-button-box')
self.image = Gtk.Image() self.image = Gtk.Image()
self.image.set_from_icon_name(icon_name, self.icon_size) self.image.set_from_icon_name(icon_name, self.icon_size)

View File

@@ -1,5 +1,6 @@
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Pango from gi.repository import Gtk, Pango

View File

@@ -1,7 +1,8 @@
from typing import Optional from typing import Optional
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import GdkPixbuf, Gtk from gi.repository import GdkPixbuf, Gtk
@@ -29,14 +30,11 @@ class SpinnerImage(Gtk.Overlay):
self.add_overlay(self.spinner) self.add_overlay(self.spinner)
def set_from_file(self, filename: Optional[str]): def set_from_file(self, filename: Optional[str]):
if filename == '': if filename == "":
filename = None filename = None
if self.image_size is not None and filename: if self.image_size is not None and filename:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename, filename, self.image_size, self.image_size, True,
self.image_size,
self.image_size,
True,
) )
self.image.set_from_pixbuf(pixbuf) self.image.set_from_pixbuf(pixbuf)
else: else:

View File

@@ -2,7 +2,8 @@ import subprocess
from typing import Any from typing import Any
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import GObject, Gtk from gi.repository import GObject, Gtk
from sublime.config import AppConfiguration, ServerConfiguration from sublime.config import AppConfiguration, ServerConfiguration
@@ -11,27 +12,27 @@ from sublime.ui.common import EditFormDialog, IconButton
class EditServerDialog(EditFormDialog): class EditServerDialog(EditFormDialog):
entity_name: str = 'Server' entity_name: str = "Server"
initial_size = (450, 250) initial_size = (450, 250)
text_fields = [ text_fields = [
('Name', 'name', False), ("Name", "name", False),
('Server address', 'server_address', False), ("Server address", "server_address", False),
('Local network address', 'local_network_address', False), ("Local network address", "local_network_address", False),
('Local network SSID', 'local_network_ssid', False), ("Local network SSID", "local_network_ssid", False),
('Username', 'username', False), ("Username", "username", False),
('Password', 'password', True), ("Password", "password", True),
] ]
boolean_fields = [ boolean_fields = [
('Play queue sync enabled', 'sync_enabled'), ("Play queue sync enabled", "sync_enabled"),
('Do not verify certificate', 'disable_cert_verify'), ("Do not verify certificate", "disable_cert_verify"),
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
test_server = Gtk.Button(label='Test Connection to Server') test_server = Gtk.Button(label="Test Connection to Server")
test_server.connect('clicked', self.on_test_server_clicked) test_server.connect("clicked", self.on_test_server_clicked)
open_in_browser = Gtk.Button(label='Open in Browser') open_in_browser = Gtk.Button(label="Open in Browser")
open_in_browser.connect('clicked', self.on_open_in_browser_clicked) open_in_browser.connect("clicked", self.on_open_in_browser_clicked)
self.extra_buttons = [(test_server, None), (open_in_browser, None)] 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): def on_test_server_clicked(self, event: Any):
# Instantiate the server. # Instantiate the server.
server_address = self.data['server_address'].get_text() server_address = self.data["server_address"].get_text()
server = Server( server = Server(
name=self.data['name'].get_text(), name=self.data["name"].get_text(),
hostname=server_address, hostname=server_address,
username=self.data['username'].get_text(), username=self.data["username"].get_text(),
password=self.data['password'].get_text(), password=self.data["password"].get_text(),
disable_cert_verify=self.data['disable_cert_verify'].get_active(), disable_cert_verify=self.data["disable_cert_verify"].get_active(),
) )
# Try to ping, and show a message box with whether or not it worked. # Try to ping, and show a message box with whether or not it worked.
@@ -55,40 +56,48 @@ class EditServerDialog(EditFormDialog):
transient_for=self, transient_for=self,
message_type=Gtk.MessageType.INFO, message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK, buttons=Gtk.ButtonsType.OK,
text='Connection to server successful.', text="Connection to server successful.",
) )
dialog.format_secondary_markup( dialog.format_secondary_markup(
f'Connection to {server_address} successful.') f"Connection to {server_address} successful."
)
except Exception as err: except Exception as err:
dialog = Gtk.MessageDialog( dialog = Gtk.MessageDialog(
transient_for=self, transient_for=self,
message_type=Gtk.MessageType.ERROR, message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK, buttons=Gtk.ButtonsType.OK,
text='Connection to server unsuccessful.', text="Connection to server unsuccessful.",
) )
dialog.format_secondary_markup( dialog.format_secondary_markup(
f'Connection to {server_address} resulted in the following ' f"Connection to {server_address} resulted in the following "
f'error:\n\n{err}') f"error:\n\n{err}"
)
dialog.run() dialog.run()
dialog.destroy() dialog.destroy()
def on_open_in_browser_clicked(self, event: Any): 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): class ConfigureServersDialog(Gtk.Dialog):
__gsignals__ = { __gsignals__ = {
'server-list-changed': "server-list-changed": (
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )), GObject.SignalFlags.RUN_FIRST,
'connected-server-changed': GObject.TYPE_NONE,
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )), (object,),
),
"connected-server-changed": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object,),
),
} }
def __init__(self, parent: Any, config: AppConfiguration): def __init__(self, parent: Any, config: AppConfiguration):
Gtk.Dialog.__init__( Gtk.Dialog.__init__(
self, self,
title='Configure Servers', title="Configure Servers",
transient_for=parent, transient_for=parent,
flags=0, flags=0,
add_buttons=(), add_buttons=(),
@@ -104,8 +113,9 @@ class ConfigureServersDialog(Gtk.Dialog):
# Server List # Server List
self.server_list = Gtk.ListBox(activate_on_single_click=False) self.server_list = Gtk.ListBox(activate_on_single_click=False)
self.server_list.connect( self.server_list.connect(
'selected-rows-changed', self.server_list_on_selected_rows_changed) "selected-rows-changed", self.server_list_on_selected_rows_changed
self.server_list.connect('row-activated', self.on_server_list_activate) )
self.server_list.connect("row-activated", self.on_server_list_activate)
flowbox.pack_start(self.server_list, True, True, 10) flowbox.pack_start(self.server_list, True, True, 10)
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 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. # Add all of the buttons to the button box.
self.buttons = [ self.buttons = [
( (
IconButton( IconButton("document-edit-symbolic", label="Edit...", relief=True,),
'document-edit-symbolic', lambda e: self.on_edit_clicked(False),
label='Edit...', "start",
relief=True, 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( IconButton(
'list-add-symbolic', "network-transmit-receive-symbolic", label="Connect", relief=True,
label='Add...', ),
relief=True, self.on_connect_clicked,
), lambda e: self.on_edit_clicked(True), 'start', False), "end",
( True,
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),
] ]
for button_cfg in self.buttons: for button_cfg in self.buttons:
btn, action, pack_end, requires_selection = button_cfg btn, action, pack_end, requires_selection = button_cfg
if pack_end == 'end': if pack_end == "end":
button_box.pack_end(btn, False, True, 5) button_box.pack_end(btn, False, True, 5)
else: else:
button_box.pack_start(btn, False, True, 5) button_box.pack_start(btn, False, True, 5)
btn.connect('clicked', action) btn.connect("clicked", action)
flowbox.pack_end(button_box, False, False, 0) flowbox.pack_end(button_box, False, False, 0)
@@ -174,8 +186,7 @@ class ConfigureServersDialog(Gtk.Dialog):
image = Gtk.Image(margin=5) image = Gtk.Image(margin=5)
if i == self.selected_server_index: if i == self.selected_server_index:
image.set_from_icon_name( image.set_from_icon_name(
'network-transmit-receive-symbolic', "network-transmit-receive-symbolic", Gtk.IconSize.SMALL_TOOLBAR,
Gtk.IconSize.SMALL_TOOLBAR,
) )
box.add(image) box.add(image)
@@ -187,41 +198,37 @@ class ConfigureServersDialog(Gtk.Dialog):
# Show them, and select the current server. # Show them, and select the current server.
self.show_all() self.show_all()
if (self.selected_server_index is not None if self.selected_server_index is not None and self.selected_server_index >= 0:
and self.selected_server_index >= 0):
self.server_list.select_row( 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): def on_remove_clicked(self, event: Any):
selected = self.server_list.get_selected_row() selected = self.server_list.get_selected_row()
if selected: if selected:
del self.server_configs[selected.get_index()] del self.server_configs[selected.get_index()]
self.refresh_server_list() 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): def on_edit_clicked(self, add: bool):
if add: if add:
dialog = EditServerDialog(self) dialog = EditServerDialog(self)
else: else:
selected_index = self.server_list.get_selected_row().get_index() selected_index = self.server_list.get_selected_row().get_index()
dialog = EditServerDialog( dialog = EditServerDialog(self, self.server_configs[selected_index])
self, self.server_configs[selected_index])
result = dialog.run() result = dialog.run()
if result == Gtk.ResponseType.OK: if result == Gtk.ResponseType.OK:
# Create a new server configuration to use. # Create a new server configuration to use.
new_config = ServerConfiguration( new_config = ServerConfiguration(
name=dialog.data['name'].get_text(), name=dialog.data["name"].get_text(),
server_address=dialog.data['server_address'].get_text(), server_address=dialog.data["server_address"].get_text(),
local_network_address=dialog.data['local_network_address'] local_network_address=dialog.data["local_network_address"].get_text(),
.get_text(), local_network_ssid=dialog.data["local_network_ssid"].get_text(),
local_network_ssid=dialog.data['local_network_ssid'].get_text( username=dialog.data["username"].get_text(),
), password=dialog.data["password"].get_text(),
username=dialog.data['username'].get_text(), sync_enabled=dialog.data["sync_enabled"].get_active(),
password=dialog.data['password'].get_text(), disable_cert_verify=dialog.data["disable_cert_verify"].get_active(),
sync_enabled=dialog.data['sync_enabled'].get_active(),
disable_cert_verify=dialog.data['disable_cert_verify']
.get_active(),
) )
if add: if add:
@@ -230,7 +237,7 @@ class ConfigureServersDialog(Gtk.Dialog):
self.server_configs[selected_index] = new_config self.server_configs[selected_index] = new_config
self.refresh_server_list() self.refresh_server_list()
self.emit('server-list-changed', self.server_configs) self.emit("server-list-changed", self.server_configs)
dialog.destroy() dialog.destroy()
@@ -239,7 +246,7 @@ class ConfigureServersDialog(Gtk.Dialog):
def on_connect_clicked(self, event: Any): def on_connect_clicked(self, event: Any):
selected_index = self.server_list.get_selected_row().get_index() 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() self.close()
def server_list_on_selected_rows_changed(self, event: Any): def server_list_on_selected_rows_changed(self, event: Any):

View File

@@ -2,39 +2,32 @@ from datetime import datetime
from typing import Any, Callable, Set from typing import Any, Callable, Set
import gi 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 gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager, SearchResult from sublime.cache_manager import CacheManager, SearchResult
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.ui import ( from sublime.ui import albums, artists, browse, player_controls, playlists, util
albums, artists, browse, player_controls, playlists, util)
from sublime.ui.common import SpinnerImage from sublime.ui.common import SpinnerImage
class MainWindow(Gtk.ApplicationWindow): class MainWindow(Gtk.ApplicationWindow):
"""Defines the main window for Sublime Music.""" """Defines the main window for Sublime Music."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'songs-removed': ( "songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
GObject.SignalFlags.RUN_FIRST, "refresh-window": (
GObject.TYPE_NONE,
(object, ),
),
'refresh-window': (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
), ),
'go-to': ( "go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str),),
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(str, str),
),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -48,20 +41,20 @@ class MainWindow(Gtk.ApplicationWindow):
Browse=browse.BrowsePanel(), Browse=browse.BrowsePanel(),
Playlists=playlists.PlaylistsPanel(), Playlists=playlists.PlaylistsPanel(),
) )
self.stack.set_transition_type( self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
self.titlebar = self._create_headerbar(self.stack) self.titlebar = self._create_headerbar(self.stack)
self.set_titlebar(self.titlebar) self.set_titlebar(self.titlebar)
self.player_controls = player_controls.PlayerControls() self.player_controls = player_controls.PlayerControls()
self.player_controls.connect( 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( 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( self.player_controls.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
@@ -69,21 +62,23 @@ class MainWindow(Gtk.ApplicationWindow):
flowbox.pack_start(self.player_controls, False, True, 0) flowbox.pack_start(self.player_controls, False, True, 0)
self.add(flowbox) 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): def update(self, app_config: AppConfiguration, force: bool = False):
# Update the Connected to label on the popup menu. # Update the Connected to label on the popup menu.
if app_config.server: if app_config.server:
self.connected_to_label.set_markup( self.connected_to_label.set_markup(
f'<b>Connected to {app_config.server.name}</b>') f"<b>Connected to {app_config.server.name}</b>"
)
else: else:
self.connected_to_label.set_markup( self.connected_to_label.set_markup(
f'<span style="italic">Not Connected to a Server</span>') '<span style="italic">Not Connected to a Server</span>'
)
self.stack.set_visible_child_name(app_config.state.current_tab) self.stack.set_visible_child_name(app_config.state.current_tab)
active_panel = self.stack.get_visible_child() active_panel = self.stack.get_visible_child()
if hasattr(active_panel, 'update'): if hasattr(active_panel, "update"):
active_panel.update(app_config, force=force) active_panel.update(app_config, force=force)
self.player_controls.update(app_config) self.player_controls.update(app_config)
@@ -92,12 +87,10 @@ class MainWindow(Gtk.ApplicationWindow):
stack = Gtk.Stack() stack = Gtk.Stack()
for name, child in kwargs.items(): for name, child in kwargs.items():
child.connect( child.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
child.connect( child.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
stack.add_titled(child, name.lower(), name) stack.add_titled(child, name.lower(), name)
return stack return stack
@@ -108,20 +101,17 @@ class MainWindow(Gtk.ApplicationWindow):
""" """
header = Gtk.HeaderBar() header = Gtk.HeaderBar()
header.set_show_close_button(True) header.set_show_close_button(True)
header.props.title = 'Sublime Music' header.props.title = "Sublime Music"
# Search # Search
self.search_entry = Gtk.SearchEntry( self.search_entry = Gtk.SearchEntry(placeholder_text="Search everything...")
placeholder_text='Search everything...') self.search_entry.connect("focus-in-event", self._on_search_entry_focus)
self.search_entry.connect( self.search_entry.connect(
'focus-in-event', self._on_search_entry_focus) "button-press-event", self._on_search_entry_button_press
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( self.search_entry.connect("changed", self._on_search_entry_changed)
'focus-out-event', self._on_search_entry_loose_focus) self.search_entry.connect("stop-search", self._on_search_entry_stop_search)
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) header.pack_start(self.search_entry)
# Search popup # Search popup
@@ -133,13 +123,13 @@ class MainWindow(Gtk.ApplicationWindow):
# Menu button # Menu button
menu_button = Gtk.MenuButton() 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_use_popover(True)
menu_button.set_popover(self._create_menu()) 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) 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) image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
menu_button.add(image) menu_button.add(image)
@@ -156,31 +146,28 @@ class MainWindow(Gtk.ApplicationWindow):
**kwargs, **kwargs,
) )
label.set_markup(text) label.set_markup(text)
label.get_style_context().add_class('search-result-row') label.get_style_context().add_class("search-result-row")
return label return label
def _create_menu(self) -> Gtk.PopoverMenu: def _create_menu(self) -> Gtk.PopoverMenu:
self.menu = Gtk.PopoverMenu() self.menu = Gtk.PopoverMenu()
self.connected_to_label = self._create_label( self.connected_to_label = self._create_label("", name="connected-to-label")
'', name='connected-to-label')
self.connected_to_label.set_markup( self.connected_to_label.set_markup(
f'<span style="italic">Not Connected to a Server</span>') '<span style="italic">Not Connected to a Server</span>'
)
menu_items = [ menu_items = [
(None, self.connected_to_label), (None, self.connected_to_label),
( ("app.configure-servers", Gtk.ModelButton(text="Configure Servers"),),
'app.configure-servers', ("app.settings", Gtk.ModelButton(text="Settings")),
Gtk.ModelButton(text='Configure Servers'),
),
('app.settings', Gtk.ModelButton(text='Settings')),
] ]
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
for name, item in menu_items: for name, item in menu_items:
if name: if name:
item.set_action_name(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) vbox.pack_start(item, False, True, 0)
self.menu.add(vbox) self.menu.add(vbox)
@@ -190,36 +177,33 @@ class MainWindow(Gtk.ApplicationWindow):
self.search_popup = Gtk.PopoverMenu(modal=False) self.search_popup = Gtk.PopoverMenu(modal=False)
results_scrollbox = Gtk.ScrolledWindow( results_scrollbox = Gtk.ScrolledWindow(
min_content_width=500, min_content_width=500, min_content_height=750,
min_content_height=750,
) )
def make_search_result_header(text: str) -> Gtk.Label: def make_search_result_header(text: str) -> Gtk.Label:
label = self._create_label(text) 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 return label
search_results_box = Gtk.Box( search_results_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL, name="search-results",
name='search-results',
) )
self.search_results_loading = Gtk.Spinner( self.search_results_loading = Gtk.Spinner(active=False, name="search-spinner")
active=False, name='search-spinner')
search_results_box.add(self.search_results_loading) 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) self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.song_results) 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) self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.album_results) 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) self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.artist_results) 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) self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.playlist_results) search_results_box.add(self.playlist_results)
@@ -238,11 +222,7 @@ class MainWindow(Gtk.ApplicationWindow):
# Event Listeners # Event Listeners
# ========================================================================= # =========================================================================
def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool: def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool:
if not self._event_in_widgets( if not self._event_in_widgets(event, self.search_entry, self.search_popup,):
event,
self.search_entry,
self.search_popup,
):
self._hide_search() self._hide_search()
if not self._event_in_widgets( if not self._event_in_widgets(
@@ -294,8 +274,7 @@ class MainWindow(Gtk.ApplicationWindow):
def create_search_callback(idx: int) -> Callable[..., Any]: def create_search_callback(idx: int) -> Callable[..., Any]:
def search_result_calback( def search_result_calback(
result: SearchResult, result: SearchResult, is_last_in_batch: bool,
is_last_in_batch: bool,
): ):
# Ignore slow returned searches. # Ignore slow returned searches.
if idx < self.latest_returned_search_idx: if idx < self.latest_returned_search_idx:
@@ -316,7 +295,8 @@ class MainWindow(Gtk.ApplicationWindow):
entry.get_text(), entry.get_text(),
search_callback=create_search_callback(self.search_idx), search_callback=create_search_callback(self.search_idx),
before_download=lambda: self._set_search_loading(True), before_download=lambda: self._set_search_loading(True),
)) )
)
self.search_idx += 1 self.search_idx += 1
@@ -355,18 +335,18 @@ class MainWindow(Gtk.ApplicationWindow):
artwork_future: CacheManager.Result, artwork_future: CacheManager.Result,
) -> Gtk.Button: ) -> Gtk.Button:
def on_search_row_button_press(*args): def on_search_row_button_press(*args):
if action_name == 'song': if action_name == "song":
goto_action_name, goto_id = 'album', value.albumId goto_action_name, goto_id = "album", value.albumId
else: else:
goto_action_name, goto_id = action_name, value.id 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() self._hide_search()
row = Gtk.Button(relief=Gtk.ReliefStyle.NONE) 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) 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(image)
box.add(self._create_label(text)) box.add(self._create_label(text))
row.add(box) row.add(box)
@@ -375,8 +355,7 @@ class MainWindow(Gtk.ApplicationWindow):
image.set_loading(False) image.set_loading(False)
image.set_from_file(f.result()) image.set_from_file(f.result())
artwork_future.add_done_callback( artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f))
lambda f: GLib.idle_add(image_callback, f))
return row return row
@@ -386,14 +365,14 @@ class MainWindow(Gtk.ApplicationWindow):
self._remove_all_from_widget(self.song_results) self._remove_all_from_widget(self.song_results)
for song in search_results.song or []: for song in search_results.song or []:
label_text = util.dot_join( label_text = util.dot_join(
f'<b>{util.esc(song.title)}</b>', f"<b>{util.esc(song.title)}</b>", util.esc(song.artist),
util.esc(song.artist),
) )
cover_art_future = CacheManager.get_cover_art_filename( cover_art_future = CacheManager.get_cover_art_filename(song.coverArt)
song.coverArt)
self.song_results.add( self.song_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'song', song, cover_art_future)) label_text, "song", song, cover_art_future
)
)
self.song_results.show_all() self.song_results.show_all()
@@ -402,14 +381,14 @@ class MainWindow(Gtk.ApplicationWindow):
self._remove_all_from_widget(self.album_results) self._remove_all_from_widget(self.album_results)
for album in search_results.album or []: for album in search_results.album or []:
label_text = util.dot_join( label_text = util.dot_join(
f'<b>{util.esc(album.name)}</b>', f"<b>{util.esc(album.name)}</b>", util.esc(album.artist),
util.esc(album.artist),
) )
cover_art_future = CacheManager.get_cover_art_filename( cover_art_future = CacheManager.get_cover_art_filename(album.coverArt)
album.coverArt)
self.album_results.add( self.album_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'album', album, cover_art_future)) label_text, "album", album, cover_art_future
)
)
self.album_results.show_all() self.album_results.show_all()
@@ -421,7 +400,9 @@ class MainWindow(Gtk.ApplicationWindow):
cover_art_future = CacheManager.get_artist_artwork(artist) cover_art_future = CacheManager.get_artist_artwork(artist)
self.artist_results.add( self.artist_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'artist', artist, cover_art_future)) label_text, "artist", artist, cover_art_future
)
)
self.artist_results.show_all() self.artist_results.show_all()
@@ -431,10 +412,13 @@ class MainWindow(Gtk.ApplicationWindow):
for playlist in search_results.playlist or []: for playlist in search_results.playlist or []:
label_text = util.esc(playlist.name) label_text = util.esc(playlist.name)
cover_art_future = CacheManager.get_cover_art_filename( cover_art_future = CacheManager.get_cover_art_filename(
playlist.coverArt) playlist.coverArt
)
self.playlist_results.add( self.playlist_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'playlist', playlist, cover_art_future)) label_text, "playlist", playlist, cover_art_future
)
)
self.playlist_results.show_all() 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) bound_y = (win_y + widget_y, win_y + widget_y + allocation.height)
# If the event is in this widget, return True immediately. # If the event is in this widget, return True immediately.
if ((bound_x[0] <= event.x_root <= bound_x[1]) if (bound_x[0] <= event.x_root <= bound_x[1]) and (
and (bound_y[0] <= event.y_root <= bound_y[1])): bound_y[0] <= event.y_root <= bound_y[1]
):
return True return True
return False return False

View File

@@ -5,7 +5,8 @@ from pathlib import Path
from typing import Any, Callable, List, Optional from typing import Any, Callable, List, Optional
import gi 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 gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
from pychromecast import Chromecast 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. Defines the player controls panel that appears at the bottom of the window.
""" """
__gsignals__ = { __gsignals__ = {
'song-scrub': ( "song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),),
GObject.SignalFlags.RUN_FIRST, "volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),),
GObject.TYPE_NONE, "device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,),),
(float, ), "song-clicked": (
),
'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.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'songs-removed': ( "songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
GObject.SignalFlags.RUN_FIRST, "refresh-window": (
GObject.TYPE_NONE,
(object, ),
),
'refresh-window': (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -66,7 +52,7 @@ class PlayerControls(Gtk.ActionBar):
def __init__(self): def __init__(self):
Gtk.ActionBar.__init__(self) Gtk.ActionBar.__init__(self)
self.set_name('player-controls-bar') self.set_name("player-controls-bar")
song_display = self.create_song_display() song_display = self.create_song_display()
playback_controls = self.create_playback_controls() playback_controls = self.create_playback_controls()
@@ -83,34 +69,40 @@ class PlayerControls(Gtk.ActionBar):
duration = ( duration = (
app_config.state.current_song.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) 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_icon(f"media-playback-{icon}-symbolic")
self.play_button.set_tooltip_text( 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_current_song = app_config.state.current_song is not None
has_next_song = False has_next_song = False
if app_config.state.repeat_type in (RepeatType.REPEAT_QUEUE, if app_config.state.repeat_type in (
RepeatType.REPEAT_SONG): RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
):
has_next_song = True has_next_song = True
elif has_current_song: elif has_current_song:
last_idx_in_queue = len(app_config.state.play_queue) - 1 last_idx_in_queue = len(app_config.state.play_queue) - 1
has_next_song = ( has_next_song = app_config.state.current_song_index < last_idx_in_queue
app_config.state.current_song_index < last_idx_in_queue)
# Toggle button states. # Toggle button states.
self.repeat_button.set_action_name(None) self.repeat_button.set_action_name(None)
self.shuffle_button.set_action_name(None) self.shuffle_button.set_action_name(None)
repeat_on = app_config.state.repeat_type in ( 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_active(repeat_on)
self.repeat_button.set_icon(app_config.state.repeat_type.icon) self.repeat_button.set_icon(app_config.state.repeat_type.icon)
self.shuffle_button.set_active(app_config.state.shuffle_on) self.shuffle_button.set_active(app_config.state.shuffle_on)
self.repeat_button.set_action_name('app.repeat-press') self.repeat_button.set_action_name("app.repeat-press")
self.shuffle_button.set_action_name('app.shuffle-press') self.shuffle_button.set_action_name("app.shuffle-press")
self.song_scrubber.set_sensitive(has_current_song) self.song_scrubber.set_sensitive(has_current_song)
self.prev_button.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 # Volume button and slider
if app_config.state.is_muted: if app_config.state.is_muted:
icon_name = 'muted' icon_name = "muted"
elif app_config.state.volume < 30: elif app_config.state.volume < 30:
icon_name = 'low' icon_name = "low"
elif app_config.state.volume < 70: elif app_config.state.volume < 70:
icon_name = 'medium' icon_name = "medium"
else: 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.editing = True
self.volume_slider.set_value( 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 self.editing = False
# Update the current song information. # Update the current song information.
@@ -143,19 +136,17 @@ class PlayerControls(Gtk.ActionBar):
order_token=self.cover_art_update_order_token, order_token=self.cover_art_update_order_token,
) )
self.song_title.set_markup( self.song_title.set_markup(util.esc(app_config.state.current_song.title))
util.esc(app_config.state.current_song.title)) self.album_name.set_markup(util.esc(app_config.state.current_song.album))
self.album_name.set_markup(
util.esc(app_config.state.current_song.album))
artist_name = util.esc(app_config.state.current_song.artist) 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: else:
# Clear out the cover art and song tite if no song # Clear out the cover art and song tite if no song
self.album_art.set_from_file(None) self.album_art.set_from_file(None)
self.album_art.set_loading(False) self.album_art.set_loading(False)
self.song_title.set_markup('') self.song_title.set_markup("")
self.album_name.set_markup('') self.album_name.set_markup("")
self.artist_name.set_markup('') self.artist_name.set_markup("")
if self.devices_requested: if self.devices_requested:
self.update_device_list() self.update_device_list()
@@ -163,11 +154,12 @@ class PlayerControls(Gtk.ActionBar):
# Set the Play Queue button popup. # Set the Play Queue button popup.
play_queue_len = len(app_config.state.play_queue) play_queue_len = len(app_config.state.play_queue)
if play_queue_len == 0: if play_queue_len == 0:
self.popover_label.set_markup('<b>Play Queue</b>') self.popover_label.set_markup("<b>Play Queue</b>")
else: else:
song_label = util.pluralize('song', play_queue_len) song_label = util.pluralize("song", play_queue_len)
self.popover_label.set_markup( self.popover_label.set_markup(
f'<b>Play Queue:</b> {play_queue_len} {song_label}') f"<b>Play Queue:</b> {play_queue_len} {song_label}"
)
self.editing_play_queue_song_list = True self.editing_play_queue_song_list = True
@@ -177,19 +169,15 @@ class PlayerControls(Gtk.ActionBar):
title = util.esc(song_details.title) title = util.esc(song_details.title)
album = util.esc(song_details.album) album = util.esc(song_details.album)
artist = util.esc(song_details.artist) artist = util.esc(song_details.artist)
return f'<b>{title}</b>\n{util.dot_join(album, artist)}' return f"<b>{title}</b>\n{util.dot_join(album, artist)}"
def make_idle_index_capturing_function( def make_idle_index_capturing_function(
idx: int, idx: int, order_tok: int, fn: Callable[[int, int, Any], None],
order_tok: int,
fn: Callable[[int, int, Any], None],
) -> Callable[[CacheManager.Result], None]: ) -> Callable[[CacheManager.Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result()) return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
def on_cover_art_future_done( def on_cover_art_future_done(
idx: int, idx: int, order_token: int, cover_art_filename: str,
order_token: int,
cover_art_filename: str,
): ):
if order_token != self.play_queue_update_order_token: if order_token != self.play_queue_update_order_token:
return return
@@ -197,9 +185,7 @@ class PlayerControls(Gtk.ActionBar):
self.play_queue_store[idx][0] = cover_art_filename self.play_queue_store[idx][0] = cover_art_filename
def on_song_details_future_done( def on_song_details_future_done(
idx: int, idx: int, order_token: int, song_details: Child,
order_token: int,
song_details: Child,
): ):
if order_token != self.play_queue_update_order_token: if order_token != self.play_queue_update_order_token:
return return
@@ -208,15 +194,15 @@ class PlayerControls(Gtk.ActionBar):
# Cover Art # Cover Art
cover_art_result = CacheManager.get_cover_art_filename( cover_art_result = CacheManager.get_cover_art_filename(
song_details.coverArt) song_details.coverArt
)
if cover_art_result.is_future: if cover_art_result.is_future:
# We don't have the cover art already cached. # We don't have the cover art already cached.
cover_art_result.add_done_callback( cover_art_result.add_done_callback(
make_idle_index_capturing_function( make_idle_index_capturing_function(
idx, idx, order_token, on_cover_art_future_done,
order_token, )
on_cover_art_future_done, )
))
else: else:
# We have the cover art already cached. # We have the cover art already cached.
self.play_queue_store[idx][0] = cover_art_result.result() 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): for i, song_id in enumerate(app_config.state.play_queue):
song_details_result = CacheManager.get_song_details(song_id) song_details_result = CacheManager.get_song_details(song_id)
cover_art_filename = '' cover_art_filename = ""
label = '\n' label = "\n"
if song_details_result.is_future: if song_details_result.is_future:
song_details_results.append((i, song_details_result)) song_details_results.append((i, song_details_result))
@@ -240,7 +226,8 @@ class PlayerControls(Gtk.ActionBar):
label = calculate_label(song_details) label = calculate_label(song_details)
cover_art_result = CacheManager.get_cover_art_filename( cover_art_result = CacheManager.get_cover_art_filename(
song_details.coverArt) song_details.coverArt
)
if cover_art_result.is_future: if cover_art_result.is_future:
# We don't have the cover art already cached. # We don't have the cover art already cached.
cover_art_result.add_done_callback( cover_art_result.add_done_callback(
@@ -248,7 +235,8 @@ class PlayerControls(Gtk.ActionBar):
i, i,
self.play_queue_update_order_token, self.play_queue_update_order_token,
on_cover_art_future_done, on_cover_art_future_done,
)) )
)
else: else:
# We have the cover art already cached. # We have the cover art already cached.
cover_art_filename = cover_art_result.result() cover_art_filename = cover_art_result.result()
@@ -259,7 +247,8 @@ class PlayerControls(Gtk.ActionBar):
label, label,
i == app_config.state.current_song_index, i == app_config.state.current_song_index,
song_id, song_id,
]) ]
)
util.diff_song_store(self.play_queue_store, new_store) util.diff_song_store(self.play_queue_store, new_store)
@@ -270,7 +259,8 @@ class PlayerControls(Gtk.ActionBar):
idx, idx,
self.play_queue_update_order_token, self.play_queue_update_order_token,
on_song_details_future_done, on_song_details_future_done,
)) )
)
self.editing_play_queue_song_list = False self.editing_play_queue_song_list = False
@@ -293,13 +283,11 @@ class PlayerControls(Gtk.ActionBar):
self.album_art.set_loading(False) self.album_art.set_loading(False)
def update_scrubber( def update_scrubber(
self, self, current: Optional[float], duration: Optional[int],
current: Optional[float],
duration: Optional[int],
): ):
if current is None or duration is None: if current is None or duration is None:
self.song_duration_label.set_text('-:--') self.song_duration_label.set_text("-:--")
self.song_progress_label.set_text('-:--') self.song_progress_label.set_text("-:--")
self.song_scrubber.set_value(0) self.song_scrubber.set_value(0)
return return
@@ -310,11 +298,12 @@ class PlayerControls(Gtk.ActionBar):
self.song_scrubber.set_value(percent_complete) self.song_scrubber.set_value(percent_complete)
self.song_duration_label.set_text(util.format_song_duration(duration)) self.song_duration_label.set_text(util.format_song_duration(duration))
self.song_progress_label.set_text( 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): def on_volume_change(self, scale: Gtk.Scale):
if not self.editing: 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): def on_play_queue_click(self, _: Any):
if self.play_queue_popover.is_visible(): 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): def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.play_queue_store], [m[-1] for m in self.play_queue_store],
{'no_reshuffle': True}, {"no_reshuffle": True},
) )
def update_device_list(self, force: bool = False): 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(): for c in self.chromecast_device_list.get_children():
self.chromecast_device_list.remove(c) self.chromecast_device_list.remove(c)
if self.current_device == 'this device': if self.current_device == "this device":
self.this_device.set_icon('audio-volume-high-symbolic') self.this_device.set_icon("audio-volume-high-symbolic")
else: else:
self.this_device.set_icon(None) self.this_device.set_icon(None)
chromecasts.sort(key=lambda c: c.device.friendly_name) chromecasts.sort(key=lambda c: c.device.friendly_name)
for cc in chromecasts: for cc in chromecasts:
icon = ( icon = (
'audio-volume-high-symbolic' "audio-volume-high-symbolic"
if str(cc.device.uuid) == self.current_device else None) if str(cc.device.uuid) == self.current_device
else None
)
btn = IconButton(icon, label=cc.device.friendly_name) 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( btn.connect(
'clicked', "clicked",
lambda _, uuid: self.emit('device-update', uuid), lambda _, uuid: self.emit("device-update", uuid),
cc.device.uuid, cc.device.uuid,
) )
self.chromecast_device_list.add(btn) self.chromecast_device_list.add(btn)
@@ -366,12 +357,13 @@ class PlayerControls(Gtk.ActionBar):
update_diff = ( update_diff = (
self.last_device_list_update self.last_device_list_update
and (datetime.now() - self.last_device_list_update).seconds > 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)): if force or len(self.chromecasts) == 0 or (update_diff and update_diff > 60):
future = ChromecastPlayer.get_chromecasts() future = ChromecastPlayer.get_chromecasts()
future.add_done_callback( future.add_done_callback(
lambda f: GLib.idle_add(chromecast_callback, f.result())) lambda f: GLib.idle_add(chromecast_callback, f.result())
)
else: else:
chromecast_callback(self.chromecasts) chromecast_callback(self.chromecasts)
@@ -387,11 +379,7 @@ class PlayerControls(Gtk.ActionBar):
def on_device_refresh_click(self, _: Any): def on_device_refresh_click(self, _: Any):
self.update_device_list(force=True) self.update_device_list(force=True)
def on_play_queue_button_press( def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton,) -> bool:
self,
tree: Any,
event: Gdk.EventButton,
) -> bool:
if event.button == 3: # Right click if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y) clicked_path = tree.get_path_at_pos(event.x, event.y)
@@ -401,7 +389,7 @@ class PlayerControls(Gtk.ActionBar):
def on_download_state_change(): def on_download_state_change():
# Refresh the entire window (no force) because the song could # Refresh the entire window (no force) because the song could
# be in a list anywhere in the window. # 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 # Use the new selection instead of the old one for calculating what
# to do the right click on. # 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] song_ids = [self.play_queue_store[p][-1] for p in paths]
remove_text = ( remove_text = (
'Remove ' + util.pluralize('song', len(song_ids)) "Remove " + util.pluralize("song", len(song_ids)) + " from queue"
+ ' from queue') )
def on_remove_songs_click(_: Any): 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( util.show_song_popover(
song_ids, song_ids,
@@ -448,10 +436,10 @@ class PlayerControls(Gtk.ActionBar):
i for i, s in enumerate(self.play_queue_store) if s[2] i for i, s in enumerate(self.play_queue_store) if s[2]
][0] ][0]
self.emit( self.emit(
'refresh-window', "refresh-window",
{ {
'current_song_index': currently_playing_index, "current_song_index": currently_playing_index,
'play_queue': [s[-1] for s in self.play_queue_store], "play_queue": [s[-1] for s in self.play_queue_store],
}, },
False, False,
) )
@@ -463,8 +451,7 @@ class PlayerControls(Gtk.ActionBar):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.album_art = SpinnerImage( self.album_art = SpinnerImage(
image_name='player-controls-album-artwork', image_name="player-controls-album-artwork", image_size=70,
image_size=70,
) )
box.pack_start(self.album_art, False, False, 5) box.pack_start(self.album_art, False, False, 5)
@@ -480,13 +467,13 @@ class PlayerControls(Gtk.ActionBar):
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
) )
self.song_title = make_label('song-title') self.song_title = make_label("song-title")
details_box.add(self.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) 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.add(self.artist_name)
details_box.pack_start(Gtk.Box(), True, True, 0) details_box.pack_start(Gtk.Box(), True, True, 0)
@@ -500,18 +487,20 @@ class PlayerControls(Gtk.ActionBar):
# Scrubber and song progress/length labels # Scrubber and song progress/length labels
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 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) scrubber_box.pack_start(self.song_progress_label, False, False, 5)
self.song_scrubber = Gtk.Scale.new_with_range( self.song_scrubber = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5) orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
self.song_scrubber.set_name('song-scrubber') )
self.song_scrubber.set_name("song-scrubber")
self.song_scrubber.set_draw_value(False) self.song_scrubber.set_draw_value(False)
self.song_scrubber.connect( 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) 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) scrubber_box.pack_start(self.song_duration_label, False, False, 5)
box.add(scrubber_box) box.add(scrubber_box)
@@ -522,8 +511,9 @@ class PlayerControls(Gtk.ActionBar):
# Repeat button # Repeat button
repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.repeat_button = IconToggleButton( self.repeat_button = IconToggleButton(
'media-playlist-repeat', 'Switch between repeat modes') "media-playlist-repeat", "Switch between repeat modes"
self.repeat_button.set_action_name('app.repeat-press') )
self.repeat_button.set_action_name("app.repeat-press")
repeat_button_box.pack_start(Gtk.Box(), True, True, 0) 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(self.repeat_button, False, False, 0)
repeat_button_box.pack_start(Gtk.Box(), True, True, 0) repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
@@ -531,35 +521,39 @@ class PlayerControls(Gtk.ActionBar):
# Previous button # Previous button
self.prev_button = IconButton( self.prev_button = IconButton(
'media-skip-backward-symbolic', "media-skip-backward-symbolic",
'Go to previous song', "Go to previous song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR) icon_size=Gtk.IconSize.LARGE_TOOLBAR,
self.prev_button.set_action_name('app.prev-track') )
self.prev_button.set_action_name("app.prev-track")
buttons.pack_start(self.prev_button, False, False, 5) buttons.pack_start(self.prev_button, False, False, 5)
# Play button # Play button
self.play_button = IconButton( self.play_button = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic",
'Play', "Play",
relief=True, relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR) icon_size=Gtk.IconSize.LARGE_TOOLBAR,
self.play_button.set_name('play-button') )
self.play_button.set_action_name('app.play-pause') 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) buttons.pack_start(self.play_button, False, False, 0)
# Next button # Next button
self.next_button = IconButton( self.next_button = IconButton(
'media-skip-forward-symbolic', "media-skip-forward-symbolic",
'Go to next song', "Go to next song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR) icon_size=Gtk.IconSize.LARGE_TOOLBAR,
self.next_button.set_action_name('app.next-track') )
self.next_button.set_action_name("app.next-track")
buttons.pack_start(self.next_button, False, False, 5) buttons.pack_start(self.next_button, False, False, 5)
# Shuffle button # Shuffle button
shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.shuffle_button = IconToggleButton( self.shuffle_button = IconToggleButton(
'media-playlist-shuffle-symbolic', 'Toggle playlist shuffling') "media-playlist-shuffle-symbolic", "Toggle playlist shuffling"
self.shuffle_button.set_action_name('app.shuffle-press') )
self.shuffle_button.set_action_name("app.shuffle-press")
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0) 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(self.shuffle_button, False, False, 0)
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0) shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
@@ -577,36 +571,28 @@ class PlayerControls(Gtk.ActionBar):
# Device button (for chromecast) # Device button (for chromecast)
self.device_button = IconButton( self.device_button = IconButton(
'video-display-symbolic', "video-display-symbolic",
'Show available audio output devices', "Show available audio output devices",
icon_size=Gtk.IconSize.LARGE_TOOLBAR, 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) box.pack_start(self.device_button, False, True, 5)
self.device_popover = Gtk.PopoverMenu( self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover",)
modal=False,
name='device-popover',
)
self.device_popover.set_relative_to(self.device_button) self.device_popover.set_relative_to(self.device_button)
device_popover_box = Gtk.Box( device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL, name="device-popover-box",
name='device-popover-box',
) )
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label( self.popover_label = Gtk.Label(
label='<b>Devices</b>', label="<b>Devices</b>", use_markup=True, halign=Gtk.Align.START, margin=5,
use_markup=True,
halign=Gtk.Align.START,
margin=5,
) )
device_popover_header.add(self.popover_label) device_popover_header.add(self.popover_label)
refresh_devices = IconButton( refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
'view-refresh-symbolic', 'Refresh device list') refresh_devices.connect("clicked", self.on_device_refresh_click)
refresh_devices.connect('clicked', self.on_device_refresh_click)
device_popover_header.pack_end(refresh_devices, False, False, 0) device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header) device_popover_box.add(device_popover_header)
@@ -614,22 +600,21 @@ class PlayerControls(Gtk.ActionBar):
device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.this_device = IconButton( self.this_device = IconButton(
'audio-volume-high-symbolic', "audio-volume-high-symbolic", label="This Device",
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( 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(self.this_device)
device_list.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) device_list.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
self.device_list_loading = Gtk.Spinner(active=True) 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) device_list.add(self.device_list_loading)
self.chromecast_device_list = Gtk.Box( self.chromecast_device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
orientation=Gtk.Orientation.VERTICAL)
device_list.add(self.chromecast_device_list) device_list.add(self.chromecast_device_list)
device_popover_box.pack_end(device_list, True, True, 0) device_popover_box.pack_end(device_list, True, True, 0)
@@ -638,25 +623,21 @@ class PlayerControls(Gtk.ActionBar):
# Play Queue button # Play Queue button
self.play_queue_button = IconButton( self.play_queue_button = IconButton(
'view-list-symbolic', "view-list-symbolic",
'Open play queue', "Open play queue",
icon_size=Gtk.IconSize.LARGE_TOOLBAR, 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) box.pack_start(self.play_queue_button, False, True, 5)
self.play_queue_popover = Gtk.PopoverMenu( self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover",)
modal=False,
name='up-next-popover',
)
self.play_queue_popover.set_relative_to(self.play_queue_button) self.play_queue_popover.set_relative_to(self.play_queue_button)
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
play_queue_popover_header = Gtk.Box( play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label( self.popover_label = Gtk.Label(
label='<b>Play Queue</b>', label="<b>Play Queue</b>",
use_markup=True, use_markup=True,
halign=Gtk.Align.START, halign=Gtk.Align.START,
margin=10, margin=10,
@@ -664,15 +645,15 @@ class PlayerControls(Gtk.ActionBar):
play_queue_popover_header.add(self.popover_label) play_queue_popover_header.add(self.popover_label)
load_play_queue = IconButton( load_play_queue = IconButton(
'folder-download-symbolic', 'Load Queue from Server', margin=5) "folder-download-symbolic", "Load Queue from Server", margin=5
load_play_queue.set_action_name('app.update-play-queue-from-server') )
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_header.pack_end(load_play_queue, False, False, 0)
play_queue_popover_box.add(play_queue_popover_header) play_queue_popover_box.add(play_queue_popover_header)
play_queue_scrollbox = Gtk.ScrolledWindow( play_queue_scrollbox = Gtk.ScrolledWindow(
min_content_height=600, min_content_height=600, min_content_width=400,
min_content_width=400,
) )
self.play_queue_store = Gtk.ListStore( self.play_queue_store = Gtk.ListStore(
@@ -682,12 +663,9 @@ class PlayerControls(Gtk.ActionBar):
str, # song ID str, # song ID
) )
self.play_queue_list = Gtk.TreeView( self.play_queue_list = Gtk.TreeView(
model=self.play_queue_store, model=self.play_queue_store, reorderable=True, headers_visible=False,
reorderable=True,
headers_visible=False,
) )
self.play_queue_list.get_selection().set_mode( self.play_queue_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
Gtk.SelectionMode.MULTIPLE)
# Album Art column. # Album Art column.
def filename_to_pixbuf( def filename_to_pixbuf(
@@ -699,48 +677,42 @@ class PlayerControls(Gtk.ActionBar):
): ):
filename = model.get_value(iter, 0) filename = model.get_value(iter, 0)
if not filename: if not filename:
cell.set_property('icon_name', '') cell.set_property("icon_name", "")
return return
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
filename, 50, 50, True)
# If this is the playing song, then overlay the play icon. # If this is the playing song, then overlay the play icon.
if model.get_value(iter, 2): if model.get_value(iter, 2):
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file( play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
str( str(Path(__file__).parent.joinpath("images/play-queue-play.png"))
Path(__file__).parent.joinpath( )
'images/play-queue-play.png')))
play_overlay_pixbuf.composite( play_overlay_pixbuf.composite(
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 255
GdkPixbuf.InterpType.NEAREST, 255) )
cell.set_property('pixbuf', pixbuf) cell.set_property("pixbuf", pixbuf)
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(55, 60) 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_cell_data_func(renderer, filename_to_pixbuf)
column.set_resizable(True) column.set_resizable(True)
self.play_queue_list.append_column(column) self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererText( renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END,)
markup=True, column = Gtk.TreeViewColumn("", renderer, markup=1)
ellipsize=Pango.EllipsizeMode.END,
)
column = Gtk.TreeViewColumn('', renderer, markup=1)
self.play_queue_list.append_column(column) 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( 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 # Set up drag-and-drop on the song list for editing the order of the
# playlist. # playlist.
self.play_queue_store.connect( self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
'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-deleted', self.on_play_queue_model_row_move)
play_queue_scrollbox.add(self.play_queue_list) play_queue_scrollbox.add(self.play_queue_list)
play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0) play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0)
@@ -749,16 +721,18 @@ class PlayerControls(Gtk.ActionBar):
# Volume mute toggle # Volume mute toggle
self.volume_mute_toggle = IconButton( self.volume_mute_toggle = IconButton(
'audio-volume-high-symbolic', 'Toggle mute') "audio-volume-high-symbolic", "Toggle mute"
self.volume_mute_toggle.set_action_name('app.mute-toggle') )
self.volume_mute_toggle.set_action_name("app.mute-toggle")
box.pack_start(self.volume_mute_toggle, False, True, 0) box.pack_start(self.volume_mute_toggle, False, True, 0)
# Volume slider # Volume slider
self.volume_slider = Gtk.Scale.new_with_range( self.volume_slider = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5) orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
self.volume_slider.set_name('volume-slider') )
self.volume_slider.set_name("volume-slider")
self.volume_slider.set_draw_value(False) 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) box.pack_start(self.volume_slider, True, True, 0)
vbox.pack_start(box, False, True, 0) vbox.pack_start(box, False, True, 0)

View File

@@ -3,7 +3,8 @@ from random import randint
from typing import Any, Iterable, List, Tuple from typing import Any, Iterable, List, Tuple
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from fuzzywuzzy import process from fuzzywuzzy import process
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
@@ -21,26 +22,27 @@ from sublime.ui.common import (
class EditPlaylistDialog(EditFormDialog): class EditPlaylistDialog(EditFormDialog):
entity_name: str = 'Playlist' entity_name: str = "Playlist"
initial_size = (350, 120) initial_size = (350, 120)
text_fields = [('Name', 'name', False), ('Comment', 'comment', False)] text_fields = [("Name", "name", False), ("Comment", "comment", False)]
boolean_fields = [('Public', 'public')] boolean_fields = [("Public", "public")]
def __init__(self, *args, **kwargs): 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)] self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)]
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class PlaylistsPanel(Gtk.Paned): class PlaylistsPanel(Gtk.Paned):
"""Defines the playlists panel.""" """Defines the playlists panel."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -55,12 +57,10 @@ class PlaylistsPanel(Gtk.Paned):
self.playlist_detail_panel = PlaylistDetailPanel() self.playlist_detail_panel = PlaylistDetailPanel()
self.playlist_detail_panel.connect( self.playlist_detail_panel.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.playlist_detail_panel.connect( self.playlist_detail_panel.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
self.pack2(self.playlist_detail_panel, True, False) self.pack2(self.playlist_detail_panel, True, False)
@@ -71,7 +71,7 @@ class PlaylistsPanel(Gtk.Paned):
class PlaylistList(Gtk.Box): class PlaylistList(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -92,57 +92,50 @@ class PlaylistList(Gtk.Box):
playlist_list_actions = Gtk.ActionBar() playlist_list_actions = Gtk.ActionBar()
new_playlist_button = IconButton( new_playlist_button = IconButton("list-add-symbolic", label="New Playlist")
'list-add-symbolic', label='New Playlist') new_playlist_button.connect("clicked", self.on_new_playlist_clicked)
new_playlist_button.connect('clicked', self.on_new_playlist_clicked)
playlist_list_actions.pack_start(new_playlist_button) playlist_list_actions.pack_start(new_playlist_button)
list_refresh_button = IconButton( list_refresh_button = IconButton(
'view-refresh-symbolic', 'Refresh list of playlists') "view-refresh-symbolic", "Refresh list of playlists"
list_refresh_button.connect('clicked', self.on_list_refresh_click) )
list_refresh_button.connect("clicked", self.on_list_refresh_click)
playlist_list_actions.pack_end(list_refresh_button) playlist_list_actions.pack_end(list_refresh_button)
self.add(playlist_list_actions) self.add(playlist_list_actions)
loading_new_playlist = Gtk.ListBox() loading_new_playlist = Gtk.ListBox()
self.loading_indicator = Gtk.ListBoxRow( self.loading_indicator = Gtk.ListBoxRow(activatable=False, selectable=False,)
activatable=False, loading_spinner = Gtk.Spinner(name="playlist-list-spinner", active=True)
selectable=False,
)
loading_spinner = Gtk.Spinner(
name='playlist-list-spinner', active=True)
self.loading_indicator.add(loading_spinner) self.loading_indicator.add(loading_spinner)
loading_new_playlist.add(self.loading_indicator) loading_new_playlist.add(self.loading_indicator)
self.new_playlist_row = Gtk.ListBoxRow( self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False)
activatable=False, selectable=False) new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False)
new_playlist_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, visible=False)
self.new_playlist_entry = Gtk.Entry( self.new_playlist_entry = Gtk.Entry(name="playlist-list-new-playlist-entry")
name='playlist-list-new-playlist-entry') self.new_playlist_entry.connect("activate", self.new_entry_activate)
self.new_playlist_entry.connect('activate', self.new_entry_activate)
new_playlist_box.add(self.new_playlist_entry) new_playlist_box.add(self.new_playlist_entry)
new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
confirm_button = IconButton( confirm_button = IconButton(
'object-select-symbolic', "object-select-symbolic",
'Create playlist', "Create playlist",
name='playlist-list-new-playlist-confirm', name="playlist-list-new-playlist-confirm",
relief=True, 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) new_playlist_actions.pack_end(confirm_button, False, True, 0)
self.cancel_button = IconButton( self.cancel_button = IconButton(
'process-stop-symbolic', "process-stop-symbolic",
'Cancel create playlist', "Cancel create playlist",
name='playlist-list-new-playlist-cancel', name="playlist-list-new-playlist-cancel",
relief=True, 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_actions.pack_end(self.cancel_button, False, True, 0)
new_playlist_box.add(new_playlist_actions) new_playlist_box.add(new_playlist_actions)
@@ -153,26 +146,26 @@ class PlaylistList(Gtk.Box):
list_scroll_window = Gtk.ScrolledWindow(min_content_width=220) list_scroll_window = Gtk.ScrolledWindow(min_content_width=220)
def create_playlist_row( def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow( row = Gtk.ListBoxRow(
action_name='app.go-to-playlist', action_name="app.go-to-playlist",
action_target=GLib.Variant('s', model.playlist_id), action_target=GLib.Variant("s", model.playlist_id),
) )
row.add( row.add(
Gtk.Label( Gtk.Label(
label=f'<b>{model.name}</b>', label=f"<b>{model.name}</b>",
use_markup=True, use_markup=True,
margin=10, margin=10,
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
max_width_chars=30, max_width_chars=30,
)) )
)
row.show_all() row.show_all()
return row return row
self.playlists_store = Gio.ListStore() 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) self.list.bind_model(self.playlists_store, create_playlist_row)
list_scroll_window.add(self.list) list_scroll_window.add(self.list)
self.pack_start(list_scroll_window, True, True, 0) self.pack_start(list_scroll_window, True, True, 0)
@@ -196,12 +189,14 @@ class PlaylistList(Gtk.Box):
new_store = [] new_store = []
selected_idx = None selected_idx = None
for i, playlist in enumerate(playlists or []): for i, playlist in enumerate(playlists or []):
if (app_config and app_config.state if (
and app_config.state.selected_playlist_id == playlist.id): app_config
and app_config.state
and app_config.state.selected_playlist_id == playlist.id
):
selected_idx = i selected_idx = i
new_store.append( new_store.append(PlaylistList.PlaylistModel(playlist.id, playlist.name))
PlaylistList.PlaylistModel(playlist.id, playlist.name))
util.diff_model_store(self.playlists_store, new_store) util.diff_model_store(self.playlists_store, new_store)
@@ -215,7 +210,7 @@ class PlaylistList(Gtk.Box):
# Event Handlers # Event Handlers
# ========================================================================= # =========================================================================
def on_new_playlist_clicked(self, _: Any): 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_entry.grab_focus()
self.new_playlist_row.show() self.new_playlist_row.show()
@@ -237,20 +232,20 @@ class PlaylistList(Gtk.Box):
self.update(force=True) self.update(force=True)
self.loading_indicator.show() self.loading_indicator.show()
playlist_ceate_future = CacheManager.create_playlist( playlist_ceate_future = CacheManager.create_playlist(name=playlist_name)
name=playlist_name)
playlist_ceate_future.add_done_callback( 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): class PlaylistDetailPanel(Gtk.Overlay):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -263,19 +258,18 @@ class PlaylistDetailPanel(Gtk.Overlay):
reordering_playlist_song_list: bool = False reordering_playlist_song_list: bool = False
def __init__(self): 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_view_scroll_window = Gtk.ScrolledWindow()
playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Playlist info panel # Playlist info panel
self.big_info_panel = Gtk.Box( self.big_info_panel = Gtk.Box(
name='playlist-info-panel', name="playlist-info-panel", orientation=Gtk.Orientation.HORIZONTAL,
orientation=Gtk.Orientation.HORIZONTAL,
) )
self.playlist_artwork = SpinnerImage( self.playlist_artwork = SpinnerImage(
image_name='playlist-album-artwork', image_name="playlist-album-artwork",
spinner_name='playlist-artwork-spinner', spinner_name="playlist-artwork-spinner",
image_size=200, image_size=200,
) )
self.big_info_panel.pack_start(self.playlist_artwork, False, False, 0) 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 # Action buttons (note we are packing end here, so we have to put them
# in right-to-left). # in right-to-left).
self.playlist_action_buttons = Gtk.Box( self.playlist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
view_refresh_button = IconButton( view_refresh_button = IconButton(
'view-refresh-symbolic', 'Refresh playlist info') "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.connect("clicked", self.on_view_refresh_click)
view_refresh_button, False, False, 5) self.playlist_action_buttons.pack_end(view_refresh_button, False, False, 5)
playlist_edit_button = IconButton( playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
'document-edit-symbolic', 'Edit paylist') playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
playlist_edit_button.connect( self.playlist_action_buttons.pack_end(playlist_edit_button, False, False, 5)
'clicked', self.on_playlist_edit_button_click)
self.playlist_action_buttons.pack_end(
playlist_edit_button, False, False, 5)
download_all_button = IconButton( 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( download_all_button.connect(
'clicked', self.on_playlist_list_download_all_button_click) "clicked", self.on_playlist_list_download_all_button_click
self.playlist_action_buttons.pack_end( )
download_all_button, False, False, 5) self.playlist_action_buttons.pack_end(download_all_button, False, False, 5)
playlist_details_box.pack_start( playlist_details_box.pack_start(self.playlist_action_buttons, False, False, 5)
self.playlist_action_buttons, False, False, 5)
playlist_details_box.pack_start(Gtk.Box(), True, False, 0) 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) 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) 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) 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) playlist_details_box.add(self.playlist_stats)
self.play_shuffle_buttons = Gtk.Box( self.play_shuffle_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, orientation=Gtk.Orientation.HORIZONTAL,
name='playlist-play-shuffle-buttons', name="playlist-play-shuffle-buttons",
) )
play_button = IconButton( play_button = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic", label="Play All", relief=True,
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) self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
shuffle_button = IconButton( shuffle_button = IconButton(
'media-playlist-shuffle-symbolic', "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True,
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) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
playlist_details_box.add(self.play_shuffle_buttons) playlist_details_box.add(self.play_shuffle_buttons)
@@ -380,8 +366,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
# TODO (#28): this is very inefficient, it's slow when the result # 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 # is close to the bottom of the list. Would be good to research
# what the default one does (maybe it uses an index?). # what the default one does (maybe it uses an index?).
max_score = max_score_for_key( max_score = max_score_for_key(key, tuple(tuple(row[1:4]) for row in model))
key, tuple(tuple(row[1:4]) for row in model))
row_max_score = row_score(key, tuple(model[treeiter][1:4])) row_max_score = row_score(key, tuple(model[treeiter][1:4]))
if row_max_score == max_score: if row_max_score == max_score:
return False # indicates match return False # indicates match
@@ -394,33 +379,31 @@ class PlaylistDetailPanel(Gtk.Overlay):
enable_search=True, enable_search=True,
) )
self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn) self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn)
self.playlist_songs.get_selection().set_mode( self.playlist_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
Gtk.SelectionMode.MULTIPLE)
# Song status column. # Song status column.
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35) renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0) column = Gtk.TreeViewColumn("", renderer, icon_name=0)
column.set_resizable(True) column.set_resizable(True)
self.playlist_songs.append_column(column) 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( self.playlist_songs.append_column(
SongListColumn('TITLE', 1, bold=True)) SongListColumn("DURATION", 4, align=1, width=40)
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))
self.playlist_songs.connect('row-activated', self.on_song_activated) self.playlist_songs.connect("row-activated", self.on_song_activated)
self.playlist_songs.connect( self.playlist_songs.connect("button-press-event", self.on_song_button_press)
'button-press-event', self.on_song_button_press)
# Set up drag-and-drop on the song list for editing the order of the # Set up drag-and-drop on the song list for editing the order of the
# playlist. # playlist.
self.playlist_song_store.connect( self.playlist_song_store.connect(
'row-inserted', 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) self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
playlist_box.add(self.playlist_songs) playlist_box.add(self.playlist_songs)
@@ -431,11 +414,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
playlist_view_spinner.start() playlist_view_spinner.start()
self.playlist_view_loading_box = Gtk.Alignment( self.playlist_view_loading_box = Gtk.Alignment(
name='playlist-view-overlay', name="playlist-view-overlay", xalign=0.5, yalign=0.5, xscale=0.1, yscale=0.1
xalign=0.5, )
yalign=0.5,
xscale=0.1,
yscale=0.1)
self.playlist_view_loading_box.add(playlist_view_spinner) self.playlist_view_loading_box.add(playlist_view_spinner)
self.add_overlay(self.playlist_view_loading_box) 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): def update(self, app_config: AppConfiguration, force: bool = False):
if app_config.state.selected_playlist_id is None: if app_config.state.selected_playlist_id is None:
self.playlist_artwork.set_from_file(None) self.playlist_artwork.set_from_file(None)
self.playlist_indicator.set_markup('') self.playlist_indicator.set_markup("")
self.playlist_name.set_markup('') self.playlist_name.set_markup("")
self.playlist_comment.hide() self.playlist_comment.hide()
self.playlist_stats.set_markup('') self.playlist_stats.set_markup("")
self.playlist_action_buttons.hide() self.playlist_action_buttons.hide()
self.play_shuffle_buttons.hide() self.play_shuffle_buttons.hide()
self.playlist_view_loading_box.hide() self.playlist_view_loading_box.hide()
@@ -484,8 +464,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_id = playlist.id self.playlist_id = playlist.id
# Update the info display. # Update the info display.
self.playlist_indicator.set_markup('PLAYLIST') self.playlist_indicator.set_markup("PLAYLIST")
self.playlist_name.set_markup(f'<b>{playlist.name}</b>') self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
if playlist.comment: if playlist.comment:
self.playlist_comment.set_text(playlist.comment) self.playlist_comment.set_text(playlist.comment)
self.playlist_comment.show() self.playlist_comment.show()
@@ -495,8 +475,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Update the artwork. # Update the artwork.
self.update_playlist_artwork( self.update_playlist_artwork(
playlist.cover_art, playlist.cover_art, order_token=order_token,
order_token=order_token,
) )
# Update the song list model. This requires some fancy diffing to # Update the song list model. This requires some fancy diffing to
@@ -505,14 +484,14 @@ class PlaylistDetailPanel(Gtk.Overlay):
new_store = [ new_store = [
[ [
util.get_cached_status_icon( util.get_cached_status_icon(CacheManager.get_cached_status(song)),
CacheManager.get_cached_status(song)),
song.title, song.title,
song.album, song.album,
song.artist, song.artist,
util.format_song_duration(song.duration), util.format_song_duration(song.duration),
song.id, song.id,
] for song in playlist.songs ]
for song in playlist.songs
] ]
util.diff_song_store(self.playlist_song_store, new_store) 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): 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) dialog = EditPlaylistDialog(self.get_toplevel(), playlist)
playlist_deleted = False playlist_deleted = False
@@ -561,9 +541,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
if result == Gtk.ResponseType.OK: if result == Gtk.ResponseType.OK:
CacheManager.update_playlist( CacheManager.update_playlist(
self.playlist_id, self.playlist_id,
name=dialog.data['name'].get_text(), name=dialog.data["name"].get_text(),
comment=dialog.data['comment'].get_text(), comment=dialog.data["comment"].get_text(),
public=dialog.data['public'].get_active(), public=dialog.data["public"].get_active(),
) )
elif result == Gtk.ResponseType.NO: elif result == Gtk.ResponseType.NO:
# Delete the playlist. # Delete the playlist.
@@ -571,7 +551,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
transient_for=self.get_toplevel(), transient_for=self.get_toplevel(),
message_type=Gtk.MessageType.WARNING, message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.NONE, buttons=Gtk.ButtonsType.NONE,
text='Confirm deletion', text="Confirm deletion",
) )
confirm_dialog.add_buttons( confirm_dialog.add_buttons(
Gtk.STOCK_DELETE, Gtk.STOCK_DELETE,
@@ -580,8 +560,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
Gtk.ResponseType.CANCEL, Gtk.ResponseType.CANCEL,
) )
confirm_dialog.format_secondary_markup( confirm_dialog.format_secondary_markup(
'Are you sure you want to delete the ' "Are you sure you want to delete the "
f'"{playlist.name}" playlist?') f'"{playlist.name}" playlist?'
)
result = confirm_dialog.run() result = confirm_dialog.run()
confirm_dialog.destroy() confirm_dialog.destroy()
if result == Gtk.ResponseType.YES: if result == Gtk.ResponseType.YES:
@@ -597,10 +578,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
CacheManager.delete_cached_cover_art(self.playlist_id) CacheManager.delete_cached_cover_art(self.playlist_id)
CacheManager.invalidate_playlists_cache() CacheManager.invalidate_playlists_cache()
self.emit( self.emit(
'refresh-window', "refresh-window",
{ {
'selected_playlist_id': "selected_playlist_id": None
None if playlist_deleted else self.playlist_id if playlist_deleted
else self.playlist_id
}, },
True, True,
) )
@@ -611,9 +593,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
def download_state_change(*args): def download_state_change(*args):
GLib.idle_add( GLib.idle_add(
lambda: self.update_playlist_view( lambda: self.update_playlist_view(
self.playlist_id, self.playlist_id, order_token=self.update_playlist_view_order_token,
order_token=self.update_playlist_view_order_token, )
)) )
song_ids = [s[-1] for s in self.playlist_song_store] song_ids = [s[-1] for s in self.playlist_song_store]
CacheManager.batch_download_songs( CacheManager.batch_download_songs(
@@ -624,43 +606,30 @@ class PlaylistDetailPanel(Gtk.Overlay):
def on_play_all_clicked(self, _: Any): def on_play_all_clicked(self, _: Any):
self.emit( self.emit(
'song-clicked', "song-clicked",
0, 0,
[m[-1] for m in self.playlist_song_store], [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): def on_shuffle_all_button(self, _: Any):
self.emit( self.emit(
'song-clicked', "song-clicked",
randint(0, randint(0, len(self.playlist_song_store) - 1),
len(self.playlist_song_store) - 1),
[m[-1] for m in self.playlist_song_store], [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): def on_song_activated(self, _: Any, idx: Gtk.TreePath, col: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.playlist_song_store], [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( def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton,) -> bool:
self,
tree: Gtk.TreeView,
event: Gdk.EventButton,
) -> bool:
if event.button == 3: # Right click if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y) clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path: if not clicked_path:
@@ -674,7 +643,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
lambda: self.update_playlist_view( lambda: self.update_playlist_view(
self.playlist_id, self.playlist_id,
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
)) )
)
# Use the new selection instead of the old one for calculating what # Use the new selection instead of the old one for calculating what
# to do the right click on. # 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] song_ids = [self.playlist_song_store[p][-1] for p in paths]
# Used to adjust for the header row. # Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords( bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
event.x, event.y) widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
def on_remove_songs_click(_: Any): def on_remove_songs_click(_: Any):
CacheManager.update_playlist( CacheManager.update_playlist(
@@ -702,8 +670,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
remove_text = ( remove_text = (
'Remove ' + util.pluralize('song', len(song_ids)) "Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
+ ' from playlist') )
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
event.x, event.x,
@@ -741,25 +709,12 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_artwork.set_loading(True) self.playlist_artwork.set_loading(True)
self.playlist_view_loading_box.show_all() self.playlist_view_loading_box.show_all()
def make_label( def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label:
self, return Gtk.Label(label=text, name=name, halign=Gtk.Align.START, **params,)
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( def _update_playlist_order(
self, self, playlist: PlaylistDetails, app_config: AppConfiguration, **kwargs,
playlist: PlaylistDetails,
app_config: AppConfiguration,
**kwargs,
): ):
self.playlist_view_loading_box.show_all() self.playlist_view_loading_box.show_all()
update_playlist_future = CacheManager.update_playlist( update_playlist_future = CacheManager.update_playlist(
@@ -774,20 +729,22 @@ class PlaylistDetailPanel(Gtk.Overlay):
playlist.id, playlist.id,
force=True, force=True,
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
))) )
)
)
def _format_stats(self, playlist: PlaylistDetails) -> str: def _format_stats(self, playlist: PlaylistDetails) -> str:
created_date = playlist.created.strftime('%B %d, %Y') created_date = playlist.created.strftime("%B %d, %Y")
lines = [ lines = [
util.dot_join( 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", f"{'Not v' if not playlist.public else 'V'}isible to others",
), ),
util.dot_join( util.dot_join(
'{} {}'.format( "{} {}".format(
playlist.song_count, playlist.song_count, util.pluralize("song", playlist.song_count)
util.pluralize("song", playlist.song_count)), ),
util.format_sequence_duration(playlist.duration), util.format_sequence_duration(playlist.duration),
), ),
] ]
return '\n'.join(lines) return "\n".join(lines)

View File

@@ -1,53 +1,51 @@
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk from gi.repository import Gtk
from .common.edit_form_dialog import EditFormDialog from .common.edit_form_dialog import EditFormDialog
class SettingsDialog(EditFormDialog): class SettingsDialog(EditFormDialog):
title: str = 'Settings' title: str = "Settings"
initial_size = (450, 250) initial_size = (450, 250)
text_fields = [ text_fields = [
( (
'Port Number (for streaming to Chromecasts on the LAN) *', "Port Number (for streaming to Chromecasts on the LAN) *",
'port_number', "port_number",
False, False,
), ),
] ]
boolean_fields = [ boolean_fields = [
('Always stream songs', 'always_stream'), ("Always stream songs", "always_stream"),
('When streaming, also download song', 'download_on_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', "Serve locally cached files over the LAN to Chromecast devices. *",
'song_play_notification', "serve_over_lan",
),
(
'Serve locally cached files over the LAN to Chromecast devices. *',
'serve_over_lan',
), ),
] ]
numeric_fields = [ numeric_fields = [
( (
'How many songs in the play queue do you want to prefetch?', "How many songs in the play queue do you want to prefetch?",
'prefetch_amount', "prefetch_amount",
(0, 10, 1), (0, 10, 1),
0, 0,
), ),
( (
'How many song downloads do you want to allow concurrently?', "How many song downloads do you want to allow concurrently?",
'concurrent_download_limit', "concurrent_download_limit",
(1, 10, 1), (1, 10, 1),
5, 5,
), ),
] ]
option_fields = [ option_fields = [
('Replay Gain', 'replay_gain', ('Disabled', 'Track', 'Album')), ("Replay Gain", "replay_gain", ("Disabled", "Track", "Album")),
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.extra_label = Gtk.Label( self.extra_label = Gtk.Label(
label='<i>* Will be appplied after restarting Sublime Music</i>', label="<i>* Will be appplied after restarting Sublime Music</i>",
justify=Gtk.Justification.LEFT, justify=Gtk.Justification.LEFT,
use_markup=True, use_markup=True,
) )

View File

@@ -12,50 +12,48 @@ class RepeatType(Enum):
@property @property
def icon(self) -> str: def icon(self) -> str:
icon_name = [ icon_name = ["repeat-symbolic", "repeat-symbolic", "repeat-song-symbolic"][
'repeat-symbolic', self.value
'repeat-symbolic', ]
'repeat-song-symbolic', return f"media-playlist-{icon_name}"
][self.value]
return f'media-playlist-{icon_name}'
def as_mpris_loop_status(self) -> str: def as_mpris_loop_status(self) -> str:
return ['None', 'Playlist', 'Track'][self.value] return ["None", "Playlist", "Track"][self.value]
@staticmethod @staticmethod
def from_mpris_loop_status(loop_status: str) -> 'RepeatType': def from_mpris_loop_status(loop_status: str) -> "RepeatType":
return { return {
'None': RepeatType.NO_REPEAT, "None": RepeatType.NO_REPEAT,
'Track': RepeatType.REPEAT_SONG, "Track": RepeatType.REPEAT_SONG,
'Playlist': RepeatType.REPEAT_QUEUE, "Playlist": RepeatType.REPEAT_QUEUE,
}[loop_status] }[loop_status]
@dataclass @dataclass
class UIState: class UIState:
"""Represents the UI state of the application.""" """Represents the UI state of the application."""
version: int = 1 version: int = 1
playing: bool = False playing: bool = False
current_song_index: int = -1 current_song_index: int = -1
play_queue: List[str] = field(default_factory=list) play_queue: List[str] = field(default_factory=list)
old_play_queue: List[str] = field(default_factory=list) old_play_queue: List[str] = field(default_factory=list)
_volume: Dict[str, float] = field( _volume: Dict[str, float] = field(default_factory=lambda: {"this device": 100.0})
default_factory=lambda: {'this device': 100.0})
is_muted: bool = False is_muted: bool = False
repeat_type: RepeatType = RepeatType.NO_REPEAT repeat_type: RepeatType = RepeatType.NO_REPEAT
shuffle_on: bool = False shuffle_on: bool = False
song_progress: float = 0 song_progress: float = 0
current_device: str = 'this device' current_device: str = "this device"
current_tab: str = 'albums' current_tab: str = "albums"
selected_album_id: Optional[str] = None selected_album_id: Optional[str] = None
selected_artist_id: Optional[str] = None selected_artist_id: Optional[str] = None
selected_browse_element_id: Optional[str] = None selected_browse_element_id: Optional[str] = None
selected_playlist_id: Optional[str] = None selected_playlist_id: Optional[str] = None
# State for Album sort. # State for Album sort.
current_album_sort: str = 'random' current_album_sort: str = "random"
current_album_genre: str = 'Rock' current_album_genre: str = "Rock"
current_album_alphabetical_sort: str = 'name' current_album_alphabetical_sort: str = "name"
current_album_from_year: int = 2010 current_album_from_year: int = 2010
current_album_to_year: int = 2020 current_album_to_year: int = 2020
@@ -67,8 +65,12 @@ class UIState:
@property @property
def current_song(self) -> Optional[Child]: def current_song(self) -> Optional[Child]:
from sublime.cache_manager import CacheManager 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 return None
current_song_id = self.play_queue[self.current_song_index] current_song_id = self.play_queue[self.current_song_index]

View File

@@ -16,12 +16,14 @@ from typing import (
import gi import gi
from deepdiff import DeepDiff from deepdiff import DeepDiff
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GLib, Gtk 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.cache_manager import CacheManager, SongCacheStatus
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.server.api_objects import Playlist
def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str: 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): if isinstance(duration_secs, timedelta):
duration_secs = round(duration_secs.total_seconds()) duration_secs = round(duration_secs.total_seconds())
if not duration_secs: 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( def pluralize(string: str, number: int, pluralized_form: str = None,) -> str:
string: str,
number: int,
pluralized_form: str = None,
) -> str:
""" """
Pluralize the given string given the count as a number. Pluralize the given string given the count as a number.
@@ -58,7 +56,7 @@ def pluralize(
'foos' 'foos'
""" """
if number != 1: if number != 1:
return pluralized_form or f'{string}s' return pluralized_form or f"{string}s"
return string return string
@@ -82,48 +80,46 @@ def format_sequence_duration(duration_secs: Union[int, timedelta]) -> str:
format_components = [] format_components = []
if duration_hrs > 0: if duration_hrs > 0:
hrs = '{} {}'.format(duration_hrs, pluralize('hour', duration_hrs)) hrs = "{} {}".format(duration_hrs, pluralize("hour", duration_hrs))
format_components.append(hrs) format_components.append(hrs)
if duration_mins > 0: if duration_mins > 0:
mins = '{} {}'.format( mins = "{} {}".format(duration_mins, pluralize("minute", duration_mins))
duration_mins, pluralize('minute', duration_mins))
format_components.append(mins) format_components.append(mins)
# Show seconds if there are no hours. # Show seconds if there are no hours.
if duration_hrs == 0: if duration_hrs == 0:
secs = '{} {}'.format( secs = "{} {}".format(duration_secs, pluralize("second", duration_secs))
duration_secs, pluralize('second', duration_secs))
format_components.append(secs) format_components.append(secs)
return ', '.join(format_components) return ", ".join(format_components)
def esc(string: Optional[str]) -> str: def esc(string: Optional[str]) -> str:
if string is None: if string is None:
return '' return ""
return string.replace('&', '&amp;').replace(" target='_blank'", '') return string.replace("&", "&amp;").replace(" target='_blank'", "")
def dot_join(*items: Any) -> str: def dot_join(*items: Any) -> str:
""" """
Joins the given strings with a dot character. Filters out None values. 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: def get_cached_status_icon(cache_status: SongCacheStatus) -> str:
cache_icon = { cache_icon = {
SongCacheStatus.NOT_CACHED: '', SongCacheStatus.NOT_CACHED: "",
SongCacheStatus.CACHED: 'folder-download-symbolic', SongCacheStatus.CACHED: "folder-download-symbolic",
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic', SongCacheStatus.PERMANENTLY_CACHED: "view-pin-symbolic",
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic', SongCacheStatus.DOWNLOADING: "emblem-synchronizing-symbolic",
} }
return cache_icon[cache_status] return cache_icon[cache_status]
def _parse_diff_location(location: str) -> Tuple: 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) 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 the lists to determine what needs to be changed.
diff = DeepDiff(old_store, new_store) diff = DeepDiff(old_store, new_store)
changed = diff.get('values_changed', {}) changed = diff.get("values_changed", {})
added = diff.get('iterable_item_added', {}) added = diff.get("iterable_item_added", {})
removed = diff.get('iterable_item_removed', {}) removed = diff.get("iterable_item_removed", {})
for edit_location, diff in changed.items(): for edit_location, diff in changed.items():
idx, field = _parse_diff_location(edit_location) 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) 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]) remove_at = int(_parse_diff_location(remove_location)[0])
del store_to_edit[remove_at] del store_to_edit[remove_at]
@@ -176,7 +172,7 @@ def show_song_popover(
position: Gtk.PositionType = Gtk.PositionType.BOTTOM, position: Gtk.PositionType = Gtk.PositionType.BOTTOM,
on_download_state_change: Callable[[], None] = lambda: None, on_download_state_change: Callable[[], None] = lambda: None,
show_remove_from_playlist_button: bool = False, 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): def on_download_songs_click(_: Any):
CacheManager.batch_download_songs( CacheManager.batch_download_songs(
@@ -187,8 +183,7 @@ def show_song_popover(
def on_remove_downloads_click(_: Any): def on_remove_downloads_click(_: Any):
CacheManager.batch_delete_cached_songs( CacheManager.batch_delete_cached_songs(
song_ids, song_ids, on_song_delete=on_download_state_change,
on_song_delete=on_download_state_change,
) )
def on_add_to_playlist_click(_: Any, playlist: Playlist): 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: if download_sensitive or status == SongCacheStatus.NOT_CACHED:
download_sensitive = True download_sensitive = True
if (remove_download_sensitive if remove_download_sensitive or status in (
or status in (SongCacheStatus.CACHED, SongCacheStatus.CACHED,
SongCacheStatus.PERMANENTLY_CACHED)): SongCacheStatus.PERMANENTLY_CACHED,
):
remove_download_sensitive = True remove_download_sensitive = True
go_to_album_button = Gtk.ModelButton( 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: 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_album_button.set_action_target_value(album_value)
go_to_artist_button = Gtk.ModelButton( 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: 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) go_to_artist_button.set_action_target_value(artist_value)
browse_to_song = Gtk.ModelButton( browse_to_song = Gtk.ModelButton(
text=f"Browse to {pluralize('song', song_count)}", text=f"Browse to {pluralize('song', song_count)}", action_name="app.browse-to",
action_name='app.browse-to',
) )
if len(parents) == 1 and list(parents)[0] is not None: 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) browse_to_song.set_action_target_value(parent_value)
menu_items = [ menu_items = [
Gtk.ModelButton( Gtk.ModelButton(
text='Play next', text="Play next",
action_name='app.play-next', action_name="app.play-next",
action_target=GLib.Variant('as', song_ids), action_target=GLib.Variant("as", song_ids),
), ),
Gtk.ModelButton( Gtk.ModelButton(
text='Add to queue', text="Add to queue",
action_name='app.add-to-queue', action_name="app.add-to-queue",
action_target=GLib.Variant('as', song_ids), action_target=GLib.Variant("as", song_ids),
), ),
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
go_to_album_button, go_to_album_button,
@@ -275,19 +272,19 @@ def show_song_popover(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
Gtk.ModelButton( Gtk.ModelButton(
text=f"Add {pluralize('song', song_count)} to playlist", 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: for item in menu_items:
if type(item) == tuple: if type(item) == tuple:
el, fn = item el, fn = item
el.connect('clicked', fn) el.connect("clicked", fn)
el.get_style_context().add_class('menu-button') el.get_style_context().add_class("menu-button")
vbox.pack_start(item[0], False, True, 0) vbox.pack_start(item[0], False, True, 0)
else: else:
item.get_style_context().add_class('menu-button') item.get_style_context().add_class("menu-button")
vbox.pack_start(item, False, True, 0) vbox.pack_start(item, False, True, 0)
popover.add(vbox) popover.add(vbox)
@@ -296,22 +293,18 @@ def show_song_popover(
playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Back button # Back button
playlists_vbox.add( playlists_vbox.add(Gtk.ModelButton(inverted=True, centered=True, menu_name="main",))
Gtk.ModelButton(
inverted=True,
centered=True,
menu_name='main',
))
# The playlist buttons # 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 = Gtk.ModelButton(text=playlist.name)
button.get_style_context().add_class('menu-button') button.get_style_context().add_class("menu-button")
button.connect('clicked', on_add_to_playlist_click, playlist) button.connect("clicked", on_add_to_playlist_click, playlist)
playlists_vbox.pack_start(button, False, True, 0) playlists_vbox.pack_start(button, False, True, 0)
popover.add(playlists_vbox) 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. # Positioning of the popover.
rect = Gdk.Rectangle() rect = Gdk.Rectangle()
@@ -340,6 +333,7 @@ def async_callback(
:param future_fn: a function which generates a :param future_fn: a function which generates a
:class:`concurrent.futures.Future` or :class:`CacheManager.Result`. :class:`concurrent.futures.Future` or :class:`CacheManager.Result`.
""" """
def decorator(callback_fn: Callable) -> Callable: def decorator(callback_fn: Callable) -> Callable:
@functools.wraps(callback_fn) @functools.wraps(callback_fn)
def wrapper( def wrapper(
@@ -351,10 +345,9 @@ def async_callback(
**kwargs, **kwargs,
): ):
if before_download: if before_download:
on_before_download = ( on_before_download = lambda: GLib.idle_add(before_download, self)
lambda: GLib.idle_add(before_download, self))
else: else:
on_before_download = (lambda: None) on_before_download = lambda: None
def future_callback(f: Union[Future, CacheManager.Result]): def future_callback(f: Union[Future, CacheManager.Result]):
try: try:
@@ -371,13 +364,11 @@ def async_callback(
app_config=app_config, app_config=app_config,
force=force, force=force,
order_token=order_token, order_token=order_token,
)) )
)
future: Union[Future, CacheManager.Result] = future_fn( future: Union[Future, CacheManager.Result] = future_fn(
*args, *args, before_download=on_before_download, force=force, **kwargs,
before_download=on_before_download,
force=force,
**kwargs,
) )
future.add_done_callback(future_callback) future.add_done_callback(future_callback)

View File

@@ -9,7 +9,7 @@ from sublime.adapters import CacheMissError
from sublime.adapters.filesystem import FilesystemAdapter from sublime.adapters.filesystem import FilesystemAdapter
from sublime.adapters.subsonic import api_objects as SubsonicAPI 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 @pytest.fixture
@@ -27,15 +27,13 @@ def cache_adapter(tmp_path: Path):
def mock_data_files( def mock_data_files(
request_name: str, request_name: str, mode: str = "r",
mode: str = 'r',
) -> Generator[Tuple[Path, Any], None, None]: ) -> Generator[Tuple[Path, Any], None, None]:
""" """
Yields all of the files in the mock_data directory that start with Yields all of the files in the mock_data directory that start with ``request_name``.
``request_name``.
""" """
for file in MOCK_DATA_FILES.iterdir(): 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: with open(file, mode) as f:
yield file, f.read() yield file, f.read()
@@ -45,8 +43,7 @@ def test_caching_get_playlists(cache_adapter: FilesystemAdapter):
cache_adapter.get_playlists() cache_adapter.get_playlists()
# Ingest an empty list (for example, no playlists added yet to server). # Ingest an empty list (for example, no playlists added yet to server).
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), [])
FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), [])
# After the first cache miss of get_playlists, even if an empty list is # After the first cache miss of get_playlists, even if an empty list is
# returned, the next one should not be a cache miss. # 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, FilesystemAdapter.FunctionNames.GET_PLAYLISTS,
(), (),
[ [
SubsonicAPI.Playlist('1', 'test1', comment='comment'), SubsonicAPI.Playlist("1", "test1", comment="comment"),
SubsonicAPI.Playlist('2', 'test2'), SubsonicAPI.Playlist("2", "test2"),
], ],
) )
playlists = cache_adapter.get_playlists() playlists = cache_adapter.get_playlists()
assert len(playlists) == 2 assert len(playlists) == 2
assert (playlists[0].id, playlists[0].name, assert (playlists[0].id, playlists[0].name, playlists[0].comment) == (
playlists[0].comment) == ('1', 'test1', 'comment') "1",
assert (playlists[1].id, playlists[1].name) == ('2', 'test2') "test1",
"comment",
)
assert (playlists[1].id, playlists[1].name) == ("2", "test2")
def test_no_caching_get_playlists(adapter: FilesystemAdapter): 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): def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError): with pytest.raises(CacheMissError):
cache_adapter.get_playlist_details('1') cache_adapter.get_playlist_details("1")
# Simulate the playlist being retrieved from Subsonic. # Simulate the playlist being retrieved from Subsonic.
songs = [ songs = [
SubsonicAPI.Song( SubsonicAPI.Song(
'2', "2",
'Song 2', "Song 2",
parent='foo', parent="foo",
album='foo', album="foo",
artist='foo', artist="foo",
duration=timedelta(seconds=20.8), duration=timedelta(seconds=20.8),
path='/foo/song2.mp3', path="/foo/song2.mp3",
), ),
SubsonicAPI.Song( SubsonicAPI.Song(
'1', "1",
'Song 1', "Song 1",
parent='foo', parent="foo",
album='foo', album="foo",
artist='foo', artist="foo",
duration=timedelta(seconds=10.2), duration=timedelta(seconds=10.2),
path='/foo/song1.mp3', path="/foo/song1.mp3",
), ),
] ]
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
('1', ), ("1",),
SubsonicAPI.PlaylistWithSongs('1', 'test1', songs=songs), SubsonicAPI.PlaylistWithSongs("1", "test1", songs=songs),
) )
playlist = cache_adapter.get_playlist_details('1') playlist = cache_adapter.get_playlist_details("1")
assert playlist.id == '1' assert playlist.id == "1"
assert playlist.name == 'test1' assert playlist.name == "test1"
assert playlist.song_count == 2 assert playlist.song_count == 2
assert playlist.duration == timedelta(seconds=31) assert playlist.duration == timedelta(seconds=31)
for actual, song in zip(playlist.songs, songs): 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 # "Force refresh" the playlist
songs = [ songs = [
SubsonicAPI.Song( SubsonicAPI.Song(
'3', "3",
'Song 3', "Song 3",
parent='foo', parent="foo",
album='foo', album="foo",
artist='foo', artist="foo",
duration=timedelta(seconds=10.2), duration=timedelta(seconds=10.2),
path='/foo/song3.mp3', path="/foo/song3.mp3",
), ),
SubsonicAPI.Song( SubsonicAPI.Song(
'1', "1",
'Song 1', "Song 1",
parent='foo', parent="foo",
album='foo', album="foo",
artist='foo', artist="foo",
duration=timedelta(seconds=21.8), duration=timedelta(seconds=21.8),
path='/foo/song1.mp3', path="/foo/song1.mp3",
), ),
SubsonicAPI.Song( SubsonicAPI.Song(
'1', "1",
'Song 1', "Song 1",
parent='foo', parent="foo",
album='foo', album="foo",
artist='foo', artist="foo",
duration=timedelta(seconds=21.8), duration=timedelta(seconds=21.8),
path='/foo/song1.mp3', path="/foo/song1.mp3",
), ),
] ]
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
('1', ), ("1",),
SubsonicAPI.PlaylistWithSongs('1', 'foo', songs=songs), SubsonicAPI.PlaylistWithSongs("1", "foo", songs=songs),
) )
playlist = cache_adapter.get_playlist_details('1') playlist = cache_adapter.get_playlist_details("1")
assert playlist.id == '1' assert playlist.id == "1"
assert playlist.name == 'foo' assert playlist.name == "foo"
assert playlist.song_count == 3 assert playlist.song_count == 3
assert playlist.duration == timedelta(seconds=53.8) assert playlist.duration == timedelta(seconds=53.8)
for actual, song in zip(playlist.songs, songs): 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 assert getattr(actual, k, None) == v
with pytest.raises(CacheMissError): 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): def test_no_caching_get_playlist_details(adapter: FilesystemAdapter):
with pytest.raises(Exception): 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 # TODO: Create a playlist (that should be allowed only if this is acting as
# a ground truth adapter) # a ground truth adapter)
@@ -186,48 +186,45 @@ def test_caching_get_playlist_then_details(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
FilesystemAdapter.FunctionNames.GET_PLAYLISTS, 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 # Trying to get playlist details should generate a cache miss, but should
# include the data that we know about. # include the data that we know about.
try: try:
cache_adapter.get_playlist_details('1') cache_adapter.get_playlist_details("1")
assert False, 'DID NOT raise CacheMissError' assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data assert e.partial_data
assert e.partial_data.id == '1' assert e.partial_data.id == "1"
assert e.partial_data.name == 'test1' assert e.partial_data.name == "test1"
# Simulate getting playlist details for id=1, then id=2 # Simulate getting playlist details for id=1, then id=2
songs = [ songs = [
SubsonicAPI.Song( SubsonicAPI.Song(
'3', "3",
'Song 3', "Song 3",
parent='foo', parent="foo",
album='foo', album="foo",
artist='foo', artist="foo",
duration=timedelta(seconds=10.2), duration=timedelta(seconds=10.2),
path='/foo/song3.mp3', path="/foo/song3.mp3",
), ),
] ]
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
('1', ), ("1",),
SubsonicAPI.PlaylistWithSongs('1', 'test1', songs=songs), SubsonicAPI.PlaylistWithSongs("1", "test1", songs=songs),
) )
cache_adapter.ingest_new_data( cache_adapter.ingest_new_data(
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS, FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
('2', ), ("2",),
SubsonicAPI.PlaylistWithSongs('2', 'test2', songs=songs), SubsonicAPI.PlaylistWithSongs("2", "test2", songs=songs),
) )
# Going back and getting playlist details for the first one should not # Going back and getting playlist details for the first one should not
# cache miss. # cache miss.
playlist = cache_adapter.get_playlist_details('1') playlist = cache_adapter.get_playlist_details("1")
assert playlist.id == '1' assert playlist.id == "1"
assert playlist.name == 'test1' assert playlist.name == "test1"

View File

@@ -12,16 +12,16 @@ from sublime.adapters.subsonic import (
SubsonicAdapter, SubsonicAdapter,
) )
MOCK_DATA_FILES = Path(__file__).parent.joinpath('mock_data') MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
@pytest.fixture @pytest.fixture
def adapter(tmp_path: Path): def adapter(tmp_path: Path):
adapter = SubsonicAdapter( adapter = SubsonicAdapter(
{ {
'server_address': 'http://subsonic.example.com', "server_address": "http://subsonic.example.com",
'username': 'test', "username": "test",
'password': 'testpass', "password": "testpass",
}, },
tmp_path, tmp_path,
) )
@@ -30,47 +30,39 @@ def adapter(tmp_path: Path):
def mock_data_files( def mock_data_files(
request_name: str, request_name: str, mode: str = "r"
mode: str = 'r',
) -> Generator[Tuple[Path, Any], None, None]: ) -> Generator[Tuple[Path, Any], None, None]:
""" """
Yields all of the files in the mock_data directory that start with Yields all of the files in the mock_data directory that start with ``request_name``.
``request_name``.
""" """
for file in MOCK_DATA_FILES.iterdir(): 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: with open(file, mode) as f:
yield file, f.read() yield file, f.read()
def mock_json(**obj: Any) -> str: def mock_json(**obj: Any) -> str:
return json.dumps( 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: def camel_to_snake(name: str) -> str:
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
def test_request_making_methods(adapter: SubsonicAdapter): def test_request_making_methods(adapter: SubsonicAdapter):
expected = { expected = {
'u': 'test', "u": "test",
'p': 'testpass', "p": "testpass",
'c': 'Sublime Music', "c": "Sublime Music",
'f': 'json', "f": "json",
'v': '1.15.0', "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( assert adapter._make_url("foo") == "http://subsonic.example.com/rest/foo.view"
'foo') == 'http://subsonic.example.com/rest/foo.view'
def test_can_service_requests(adapter: SubsonicAdapter): 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 assert adapter.can_service_requests is False
# Simulate some sort of ping error # 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.info(filename)
logging.debug(data) logging.debug(data)
adapter._set_mock_data(data) adapter._set_mock_data(data)
@@ -93,32 +85,32 @@ def test_can_service_requests(adapter: SubsonicAdapter):
def test_get_playlists(adapter: SubsonicAdapter): def test_get_playlists(adapter: SubsonicAdapter):
expected = [ expected = [
SubsonicAPI.Playlist( SubsonicAPI.Playlist(
id='2', id="2",
name='Test', name="Test",
song_count=132, song_count=132,
duration=timedelta(seconds=33072), duration=timedelta(seconds=33072),
created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=timezone.utc), created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=timezone.utc),
changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=timezone.utc), changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=timezone.utc),
comment='Foo', comment="Foo",
owner='foo', owner="foo",
public=True, public=True,
cover_art='pl-2', cover_art="pl-2",
), ),
SubsonicAPI.Playlist( SubsonicAPI.Playlist(
id='3', id="3",
name='Bar', name="Bar",
song_count=23, song_count=23,
duration=timedelta(seconds=847), duration=timedelta(seconds=847),
created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=timezone.utc), created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=timezone.utc),
changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=timezone.utc), changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=timezone.utc),
comment='', comment="",
owner='foo', owner="foo",
public=False, 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.info(filename)
logging.debug(data) logging.debug(data)
adapter._set_mock_data(data) adapter._set_mock_data(data)
@@ -130,12 +122,12 @@ def test_get_playlists(adapter: SubsonicAdapter):
def test_get_playlist_details(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.info(filename)
logging.debug(data) logging.debug(data)
adapter._set_mock_data(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. # Make sure that the song count is correct even if it's not provided.
# Old versions of Subsonic don't have these properties. # 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. # Make sure that at least the first song got decoded properly.
assert playlist_details.songs[0] == SubsonicAPI.Song( assert playlist_details.songs[0] == SubsonicAPI.Song(
id='202', id="202",
parent='318', parent="318",
title='What a Beautiful Name', title="What a Beautiful Name",
album='What a Beautiful Name - Single', album="What a Beautiful Name - Single",
artist='Hillsong Worship', artist="Hillsong Worship",
track=1, track=1,
year=2016, year=2016,
genre='Christian & Gospel', genre="Christian & Gospel",
cover_art='318', cover_art="318",
size=8381640, size=8381640,
content_type="audio/mp4", content_type="audio/mp4",
suffix="m4a", suffix="m4a",
@@ -160,12 +152,13 @@ def test_get_playlist_details(adapter: SubsonicAdapter):
transcoded_suffix="mp3", transcoded_suffix="mp3",
duration=timedelta(seconds=238), duration=timedelta(seconds=238),
bit_rate=256, bit_rate=256,
path='/'.join( path="/".join(
( (
'Hillsong Worship', "Hillsong Worship",
'What a Beautiful Name - Single', "What a Beautiful Name - Single",
'01 What a Beautiful Name.m4a', "01 What a Beautiful Name.m4a",
)), )
),
is_video=False, is_video=False,
play_count=20, play_count=20,
disc_number=1, disc_number=1,

View File

@@ -4,38 +4,32 @@ from pathlib import Path
import yaml import yaml
from sublime.config import ( from sublime.config import AppConfiguration, ReplayGainType, ServerConfiguration
AppConfiguration, ReplayGainType, ServerConfiguration)
def test_config_default_cache_location(): def test_config_default_cache_location():
config = AppConfiguration() config = AppConfiguration()
assert config.cache_location == os.path.expanduser( assert config.cache_location == os.path.expanduser("~/.local/share/sublime-music")
'~/.local/share/sublime-music')
def test_server_property(): def test_server_property():
config = AppConfiguration() config = AppConfiguration()
server = ServerConfiguration( server = ServerConfiguration(name="foo", server_address="bar", username="baz")
name='foo', server_address='bar', username='baz')
config.servers.append(server) config.servers.append(server)
assert config.server is None assert config.server is None
config.current_server_index = 0 config.current_server_index = 0
assert asdict(config.server) == asdict(server) 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( expected_state_file_location = expected_state_file_location.joinpath(
'sublime-music', "sublime-music", "6df23dc03f9b54cc38a0fc1483df6e21", "state.pickle",
'6df23dc03f9b54cc38a0fc1483df6e21',
'state.pickle',
) )
assert config.state_file_location == expected_state_file_location assert config.state_file_location == expected_state_file_location
def test_yaml_load_unload(): def test_yaml_load_unload():
config = AppConfiguration() config = AppConfiguration()
server = ServerConfiguration( server = ServerConfiguration(name="foo", server_address="bar", username="baz")
name='foo', server_address='bar', username='baz')
config.servers.append(server) config.servers.append(server)
config.current_server_index = 0 config.current_server_index = 0
@@ -54,9 +48,7 @@ def test_yaml_load_unload():
def test_config_migrate(): def test_config_migrate():
config = AppConfiguration() config = AppConfiguration()
server = ServerConfiguration( server = ServerConfiguration(
name='Test', name="Test", server_address="https://test.host", username="test"
server_address='https://test.host',
username='test',
) )
config.servers.append(server) config.servers.append(server)
config.migrate() config.migrate()