Run black on entire project
This commit is contained in:
23
.editorconfig
Normal file
23
.editorconfig
Normal 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
|
@@ -19,6 +19,7 @@ lint:
|
||||
- ./cicd/install-project-deps.sh
|
||||
script:
|
||||
- pipenv run python setup.py check -mrs
|
||||
- pipenv run black --check .
|
||||
- pipenv run flake8
|
||||
- pipenv run mypy sublime tests/**/*.py
|
||||
- pipenv run cicd/custom_style_check.py
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.mypyEnabled": true,
|
||||
"python.formatting.provider": "yapf"
|
||||
"python.formatting.provider": "black"
|
||||
}
|
||||
|
@@ -78,12 +78,41 @@ Building the flatpak
|
||||
Code Style
|
||||
----------
|
||||
|
||||
* `PEP-8`_ is to be followed **strictly**.
|
||||
* `mypy`_ is used for type checking.
|
||||
* ``print`` statements are not to be used except for when you actually want to
|
||||
print to the terminal (which should be rare). In all other cases, the more
|
||||
powerful and useful ``logging`` library should be used.
|
||||
This project follows `PEP-8`_ **strictly**. The *only* exception is maximum line
|
||||
length, which is 88 for this project (in accordance with ``black``'s defaults).
|
||||
Additionally, lines that contain a single string literal are allowed to extend
|
||||
past that.
|
||||
Additionally, this project uses ``black`` to enforce consistent, deterministic
|
||||
code style.
|
||||
|
||||
Although you can technically do all of the formatting yourself, it is
|
||||
recommended that you use the following tools (they are automatically installed
|
||||
if you are using pipenv). The CI process uses these to check all commits, so you
|
||||
will probably want these so you don't have to wait for results of the build
|
||||
before knowing if your code is the correct style.
|
||||
|
||||
* `flake8`_ is used for linting. The following additional plugins are also used:
|
||||
|
||||
* ``flake8-annotations``: enforce type annotations on function definitions.
|
||||
* ``flake8-comprehensions``: enforce usage of comprehensions wherever
|
||||
possible.
|
||||
* ``flake8-importorder`` (with the ``edited`` import style): enforce ordering
|
||||
of import statements.
|
||||
* ``flake8-pep3101``: no ``%`` string formatting.
|
||||
* ``flake8-print`` no print statements. Use the more powerful and useful
|
||||
``logging`` library instead. In the rare case that you actually want to
|
||||
print to the terminal (the ``--version`` flag for example), then just
|
||||
disable this check with a ``# noqa: T001`` comment.
|
||||
|
||||
* `mypy`_ is used for type checking. All type errors must be resolved.
|
||||
* `black`_ is used for auto-formatting. The CI process runs ``black --check`` to
|
||||
make sure that you've run ``black`` on all files (or are just good at manually
|
||||
formatting).
|
||||
* ``TODO`` statements must include an associated issue number (in other words,
|
||||
if you want to check in a change with outstanding TODOs, there must be an
|
||||
issue associated with it to fix it).
|
||||
|
||||
.. _black: https://github.com/psf/black
|
||||
.. _`PEP-8`: https://www.python.org/dev/peps/pep-0008/
|
||||
.. _mypy: http://mypy-lang.org/
|
||||
|
||||
|
11
Pipfile
11
Pipfile
@@ -4,10 +4,12 @@ url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[dev-packages]
|
||||
dataclasses-json = {git = "https://github.com/sumnerevans/dataclasses-json",ref = "cc2eaeb"}
|
||||
black = "*"
|
||||
dataclasses-json = {git = "https://github.com/lidatong/dataclasses-json",ref = "master"}
|
||||
docutils = "*"
|
||||
flake8 = "*"
|
||||
flake8 = {git = "https://gitlab.com/pycqa/flake8",ref = "master"}
|
||||
flake8-annotations = "*"
|
||||
flake8-bugbear = "*"
|
||||
flake8-comprehensions = "*"
|
||||
flake8-import-order = "*"
|
||||
flake8-pep3101 = "*"
|
||||
@@ -15,6 +17,7 @@ flake8-print = "*"
|
||||
graphviz = "*"
|
||||
lxml = "*"
|
||||
mypy = "*"
|
||||
pycodestyle = "==2.6.0a1"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
rope = "*"
|
||||
@@ -22,10 +25,12 @@ rst2html5 = "*"
|
||||
sphinx = "*"
|
||||
sphinx-rtd-theme = "*"
|
||||
termcolor = "*"
|
||||
yapf = "*"
|
||||
|
||||
[packages]
|
||||
sublime-music = {editable = true,extras = ["keyring"],path = "."}
|
||||
|
||||
[requires]
|
||||
python_version = "3.8"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
|
219
Pipfile.lock
generated
219
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "1b3ed7bc26fc014d648a0fddf4cde814f5ea583a464bf457548326a67825601c"
|
||||
"sha256": "dfed6ede3c95b6007e782cdce8117157f9d5e02aed39af78acf6055bb6dd9d75"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -192,26 +192,24 @@
|
||||
},
|
||||
"protobuf": {
|
||||
"hashes": [
|
||||
"sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab",
|
||||
"sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f",
|
||||
"sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a",
|
||||
"sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0",
|
||||
"sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4",
|
||||
"sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2",
|
||||
"sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee",
|
||||
"sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07",
|
||||
"sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151",
|
||||
"sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a",
|
||||
"sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f",
|
||||
"sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7",
|
||||
"sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956",
|
||||
"sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306",
|
||||
"sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961",
|
||||
"sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481",
|
||||
"sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a",
|
||||
"sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80"
|
||||
"sha256:03b31ec00ad94d4947fd87f49b288e60f443370fd1927fae80411d2dd864fbb5",
|
||||
"sha256:0b845c1fb8f36be203cd2ca9e405a22ee2cec2ed87d180b067d7c063f5701633",
|
||||
"sha256:0d1fec40323c8e10812897c71453c33401f6ccc6ade98c5a3fef1f019de797e6",
|
||||
"sha256:375ab5683efc946d1340dcf53dd422ccb55fbe88c0e16408182ca9a73248d91e",
|
||||
"sha256:3c5a1a0acd42a3fa39ce0b1436cd7faaa1e77ecaac58cd87101f56b2fe99f628",
|
||||
"sha256:3f01f6a479aff857615f2decaba773470816727fa6be6291866bd966d6ae3c61",
|
||||
"sha256:4cae6edd604ddbbaadd90da13df06fdf399d3fa9f19950e78340ab69f59f103c",
|
||||
"sha256:4edae95bff0e4a010059462b4a0116366863573c105ba689fc19ed9dae16888d",
|
||||
"sha256:67412c3eb0299a2c908d86dea1ceab9e65558684abd2f53e9f85ae28f03ba7b3",
|
||||
"sha256:8765978e2e553a7a9a7d4aa64b957f111a0358d85d799e378dc458b653ea2de5",
|
||||
"sha256:8bba760eb61044120cb91552f55c4b2fa3a80c8639fae8583b53b3e3a7e8da56",
|
||||
"sha256:996542402404aa8577defcdebbf9a0780bd96c7af2f562eefd4542716ca369a1",
|
||||
"sha256:a4cb8388c3f75d36ac51667e678f4c3096f672229d3e68d1db18675d4f59e5a2",
|
||||
"sha256:b8404d27772f130299185e20e4379a2b3450c7d1197396131cc2ec4626db75cb",
|
||||
"sha256:b9c9692d2842ff7846b0c2574be8e921247b7c377f4c03cd6370aef077fb652c",
|
||||
"sha256:caed753a89e5ffc2fbbf624926eacc3924c884181374bd3ddf54ca0a2903eb11"
|
||||
],
|
||||
"version": "==3.11.3"
|
||||
"version": "==3.12.0rc1"
|
||||
},
|
||||
"pycairo": {
|
||||
"hashes": [
|
||||
@@ -354,6 +352,13 @@
|
||||
],
|
||||
"version": "==0.7.12"
|
||||
},
|
||||
"appdirs": {
|
||||
"hashes": [
|
||||
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
|
||||
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
|
||||
],
|
||||
"version": "==1.4.3"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
||||
@@ -368,6 +373,14 @@
|
||||
],
|
||||
"version": "==2.8.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
|
||||
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==19.10b0"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
|
||||
@@ -382,6 +395,13 @@
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
],
|
||||
"version": "==7.1.2"
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
"sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
|
||||
@@ -433,20 +453,9 @@
|
||||
"index": "pypi",
|
||||
"version": "==0.16"
|
||||
},
|
||||
"entrypoints": {
|
||||
"hashes": [
|
||||
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
|
||||
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
|
||||
],
|
||||
"version": "==0.3"
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
|
||||
"sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.7.9"
|
||||
"git": "https://gitlab.com/pycqa/flake8",
|
||||
"ref": "0c3b8045a7b51aec7abf19dea94d5292cebeeea0"
|
||||
},
|
||||
"flake8-annotations": {
|
||||
"hashes": [
|
||||
@@ -456,6 +465,14 @@
|
||||
"index": "pypi",
|
||||
"version": "==2.1.0"
|
||||
},
|
||||
"flake8-bugbear": {
|
||||
"hashes": [
|
||||
"sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63",
|
||||
"sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.1.4"
|
||||
},
|
||||
"flake8-comprehensions": {
|
||||
"hashes": [
|
||||
"sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2",
|
||||
@@ -518,10 +535,10 @@
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||
"sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
|
||||
"sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
|
||||
],
|
||||
"version": "==2.11.2"
|
||||
"version": "==3.0.0a1"
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
@@ -558,41 +575,30 @@
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
||||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
|
||||
"sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f",
|
||||
"sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db",
|
||||
"sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7",
|
||||
"sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a",
|
||||
"sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054",
|
||||
"sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977",
|
||||
"sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0",
|
||||
"sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4",
|
||||
"sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba",
|
||||
"sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761",
|
||||
"sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3",
|
||||
"sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0",
|
||||
"sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8",
|
||||
"sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d",
|
||||
"sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1",
|
||||
"sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45",
|
||||
"sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e",
|
||||
"sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1",
|
||||
"sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428",
|
||||
"sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b",
|
||||
"sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6",
|
||||
"sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
"version": "==2.0.0a1"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
@@ -642,6 +648,13 @@
|
||||
],
|
||||
"version": "==20.3"
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
|
||||
"sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
|
||||
],
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
@@ -658,17 +671,18 @@
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
|
||||
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
|
||||
"sha256:933bfe8d45355fbb35f9017d81fc51df8cb7ce58b82aca2568b870bf7bea1611",
|
||||
"sha256:c1362bf675a7c0171fa5f795917c570c2e405a97e5dc473b51f3656075d73acc"
|
||||
],
|
||||
"version": "==2.5.0"
|
||||
"index": "pypi",
|
||||
"version": "==2.6.0a1"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
|
||||
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
|
||||
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
|
||||
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
|
||||
],
|
||||
"version": "==2.1.1"
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
@@ -679,10 +693,10 @@
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
"sha256:67199f0c41a9c702154efb0e7a8cc08accf830eb003b4d9fa42c4059002e2492",
|
||||
"sha256:700d17888d441604b0bd51535908dcb297561b040819cccde647a92439db5a2a"
|
||||
],
|
||||
"version": "==2.4.7"
|
||||
"version": "==3.0.0a1"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
@@ -707,6 +721,32 @@
|
||||
],
|
||||
"version": "==2020.1"
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b",
|
||||
"sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8",
|
||||
"sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3",
|
||||
"sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e",
|
||||
"sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683",
|
||||
"sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1",
|
||||
"sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142",
|
||||
"sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3",
|
||||
"sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468",
|
||||
"sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e",
|
||||
"sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3",
|
||||
"sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a",
|
||||
"sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f",
|
||||
"sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6",
|
||||
"sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156",
|
||||
"sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b",
|
||||
"sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db",
|
||||
"sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd",
|
||||
"sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a",
|
||||
"sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948",
|
||||
"sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89"
|
||||
],
|
||||
"version": "==2020.4.4"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
|
||||
@@ -752,11 +792,11 @@
|
||||
},
|
||||
"sphinx-rtd-theme": {
|
||||
"hashes": [
|
||||
"sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4",
|
||||
"sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a"
|
||||
"sha256:1ba9bbc8898ed8531ac8d140b4ff286d57010fb878303b2efae3303726ec821b",
|
||||
"sha256:a18194ae459f6a59b0d56e4a8b4c576c0125fb9a12f2211e652b4a8133092e14"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.4.3"
|
||||
"version": "==0.5.0rc1"
|
||||
},
|
||||
"sphinxcontrib-applehelp": {
|
||||
"hashes": [
|
||||
@@ -807,6 +847,13 @@
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
|
||||
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"typed-ast": {
|
||||
"hashes": [
|
||||
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
||||
@@ -854,14 +901,6 @@
|
||||
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
|
||||
],
|
||||
"version": "==0.1.9"
|
||||
},
|
||||
"yapf": {
|
||||
"hashes": [
|
||||
"sha256:3000abee4c28daebad55da6c85f3cd07b8062ce48e2e9943c8da1b9667d48427",
|
||||
"sha256:3abf61ba67cf603069710d30acbc88cfe565d907e16ad81429ae90ce9651e0c9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.30.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,18 +6,18 @@ from pathlib import Path
|
||||
|
||||
from termcolor import cprint
|
||||
|
||||
todo_re = re.compile(r'#\s*TODO:?\s*')
|
||||
accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)')
|
||||
todo_re = re.compile(r"#\s*TODO:?\s*")
|
||||
accounted_for_todo = re.compile(r"#\s*TODO:?\s*\((#\d+)\)")
|
||||
|
||||
|
||||
def check_file(path: Path) -> bool:
|
||||
print(f'Checking {path.absolute()}...') # noqa: T001
|
||||
print(f"Checking {path.absolute()}...") # noqa: T001
|
||||
file = path.open()
|
||||
valid = True
|
||||
|
||||
for i, line in enumerate(file, start=1):
|
||||
if todo_re.search(line) and not accounted_for_todo.search(line):
|
||||
cprint(f'{i}: {line}', 'red', end='', attrs=['bold'])
|
||||
cprint(f"{i}: {line}", "red", end="", attrs=["bold"])
|
||||
valid = False
|
||||
|
||||
file.close()
|
||||
@@ -25,7 +25,7 @@ def check_file(path: Path) -> bool:
|
||||
|
||||
|
||||
valid = True
|
||||
for path in Path('sublime').glob('**/*.py'):
|
||||
for path in Path("sublime").glob("**/*.py"):
|
||||
valid &= check_file(path)
|
||||
print() # noqa: T001
|
||||
|
||||
|
43
docs/conf.py
43
docs/conf.py
@@ -18,14 +18,15 @@
|
||||
|
||||
import datetime
|
||||
|
||||
project = 'Sublime Music'
|
||||
copyright = f'{datetime.datetime.today().year}, Sumner Evans'
|
||||
author = 'Sumner Evans'
|
||||
gitlab = 'https://gitlab.com/sumner/sublime-music/'
|
||||
project = "Sublime Music"
|
||||
copyright = f"{datetime.datetime.today().year}, Sumner Evans"
|
||||
author = "Sumner Evans"
|
||||
gitlab = "https://gitlab.com/sumner/sublime-music/"
|
||||
|
||||
# Get the version from the package.
|
||||
import sublime
|
||||
release = f'v{sublime.__version__}'
|
||||
|
||||
release = f"v{sublime.__version__}"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
@@ -33,37 +34,37 @@ release = f'v{sublime.__version__}'
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.autosectionlabel',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.mathjax',
|
||||
'sphinx.ext.viewcode',
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.autosectionlabel",
|
||||
"sphinx.ext.intersphinx",
|
||||
"sphinx.ext.mathjax",
|
||||
"sphinx.ext.viewcode",
|
||||
]
|
||||
|
||||
autodoc_default_options = {
|
||||
'members': True,
|
||||
'undoc-members': True,
|
||||
'show-inheritance': True,
|
||||
'special-members': '__init__',
|
||||
"members": True,
|
||||
"undoc-members": True,
|
||||
"show-inheritance": True,
|
||||
"special-members": "__init__",
|
||||
}
|
||||
autosectionlabel_prefix_document = True
|
||||
intersphinx_mapping = {
|
||||
'python': ('https://docs.python.org/3', None),
|
||||
"python": ("https://docs.python.org/3", None),
|
||||
}
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
templates_path = ["_templates"]
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
master_doc = "index"
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
pygments_style = "sphinx"
|
||||
|
||||
rst_epilog = f"""
|
||||
-------------------------------------------------------------------------------
|
||||
@@ -81,9 +82,9 @@ rst_epilog = f"""
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
html_static_path = ["_static"]
|
||||
|
@@ -1,6 +1,8 @@
|
||||
[flake8]
|
||||
ignore = E402, W503, ANN002, ANN003, ANN101, ANN102, ANN204
|
||||
select = C,E,F,W,B,B950
|
||||
ignore = E402, E501, W503, ANN002, ANN003, ANN101, ANN102, ANN204
|
||||
exclude = .git,__pycache__,build,dist,flatpak
|
||||
max-line-length = 80
|
||||
suppress-none-returning = True
|
||||
suppress-dummy-args = True
|
||||
application-import-names = sublime
|
||||
|
91
setup.py
91
setup.py
@@ -5,83 +5,72 @@ from setuptools import find_packages, setup
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
with codecs.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f:
|
||||
with codecs.open(os.path.join(here, "README.rst"), encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
|
||||
# Find the version
|
||||
with codecs.open(os.path.join(here, 'sublime/__init__.py'),
|
||||
encoding='utf-8') as f:
|
||||
with codecs.open(os.path.join(here, "sublime/__init__.py"), encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.startswith('__version__'):
|
||||
if line.startswith("__version__"):
|
||||
version = eval(line.split()[-1])
|
||||
break
|
||||
|
||||
setup(
|
||||
name='sublime-music',
|
||||
name="sublime-music",
|
||||
version=version,
|
||||
url='https://gitlab.com/sumner/sublime-music',
|
||||
description='A native GTK *sonic client.',
|
||||
url="https://gitlab.com/sumner/sublime-music",
|
||||
description="A native GTK *sonic client.",
|
||||
long_description=long_description,
|
||||
author='Sumner Evans',
|
||||
author_email='inquiries@sumnerevans.com',
|
||||
license='GPL3',
|
||||
author="Sumner Evans",
|
||||
author_email="inquiries@sumnerevans.com",
|
||||
license="GPL3",
|
||||
classifiers=[
|
||||
# 3 - Alpha
|
||||
# 4 - Beta
|
||||
# 5 - Production/Stable
|
||||
'Development Status :: 3 - Alpha',
|
||||
|
||||
"Development Status :: 3 - Alpha",
|
||||
# Indicate who your project is intended for
|
||||
'Intended Audience :: End Users/Desktop',
|
||||
'Operating System :: POSIX',
|
||||
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
||||
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Operating System :: POSIX",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
# Specify the Python versions you support here. In particular, ensure
|
||||
# that you indicate whether you support Python 2, Python 3 or both.
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
],
|
||||
keywords='airsonic subsonic libresonic gonic music',
|
||||
packages=find_packages(exclude=['tests']),
|
||||
keywords="airsonic subsonic libresonic gonic music",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
package_data={
|
||||
'sublime': [
|
||||
'ui/app_styles.css',
|
||||
'ui/images/play-queue-play.png',
|
||||
'ui/images/default-album-art.png',
|
||||
'dbus/mpris_specs/org.mpris.MediaPlayer2.xml',
|
||||
'dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml',
|
||||
'dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml',
|
||||
'dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml',
|
||||
"sublime": [
|
||||
"ui/app_styles.css",
|
||||
"ui/images/play-queue-play.png",
|
||||
"ui/images/default-album-art.png",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml",
|
||||
]
|
||||
},
|
||||
install_requires=[
|
||||
'bottle',
|
||||
'dataclasses-json @ git+https://github.com/sumnerevans/dataclasses-json@cc2eaeb#egg=dataclasses-json', # noqa: E501
|
||||
'deepdiff',
|
||||
'Deprecated',
|
||||
'fuzzywuzzy',
|
||||
"bottle",
|
||||
"dataclasses-json @ git+https://github.com/sumnerevans/dataclasses-json@cc2eaeb#egg=dataclasses-json", # noqa: E501
|
||||
"deepdiff",
|
||||
"Deprecated",
|
||||
"fuzzywuzzy",
|
||||
'osxmmkeys ; sys_platform=="darwin"',
|
||||
'peewee',
|
||||
'pychromecast',
|
||||
'PyGObject',
|
||||
'python-dateutil',
|
||||
'python-Levenshtein',
|
||||
'python-mpv',
|
||||
'pyyaml',
|
||||
'requests',
|
||||
"peewee",
|
||||
"pychromecast",
|
||||
"PyGObject",
|
||||
"python-dateutil",
|
||||
"python-Levenshtein",
|
||||
"python-mpv",
|
||||
"pyyaml",
|
||||
"requests",
|
||||
],
|
||||
extras_require={
|
||||
"keyring": ["keyring"],
|
||||
},
|
||||
|
||||
|
||||
extras_require={"keyring": ["keyring"]},
|
||||
# To provide executable scripts, use entry points in preference to the
|
||||
# "scripts" keyword. Entry points provide cross-platform support and
|
||||
# allow pip to create the appropriate form of executable for the target
|
||||
# platform.
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'sublime-music=sublime.__main__:main',
|
||||
],
|
||||
},
|
||||
entry_points={"console_scripts": ["sublime-music=sublime.__main__:main"]},
|
||||
)
|
||||
|
@@ -5,7 +5,8 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa: F401
|
||||
|
||||
import sublime
|
||||
@@ -13,55 +14,50 @@ from sublime.app import SublimeMusicApp
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Sublime Music')
|
||||
parser = argparse.ArgumentParser(description="Sublime Music")
|
||||
parser.add_argument(
|
||||
'-v',
|
||||
'--version',
|
||||
help='show version and exit',
|
||||
action='store_true',
|
||||
"-v", "--version", help="show version and exit", action="store_true"
|
||||
)
|
||||
parser.add_argument("-l", "--logfile", help="the filename to send logs to")
|
||||
parser.add_argument(
|
||||
"-m", "--loglevel", help="the minium level of logging to do", default="WARNING",
|
||||
)
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--logfile',
|
||||
help='the filename to send logs to',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-m',
|
||||
'--loglevel',
|
||||
help='the minium level of logging to do',
|
||||
default='WARNING',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--config',
|
||||
help='specify a configuration file. Defaults to '
|
||||
'~/.config/sublime-music/config.json',
|
||||
"-c",
|
||||
"--config",
|
||||
help="specify a configuration file. Defaults to "
|
||||
"~/.config/sublime-music/config.json",
|
||||
)
|
||||
|
||||
args, unknown_args = parser.parse_known_args()
|
||||
if args.version:
|
||||
print(f'Sublime Music v{sublime.__version__}') # noqa: T001
|
||||
print(f"Sublime Music v{sublime.__version__}") # noqa: T001
|
||||
return
|
||||
|
||||
min_log_level = getattr(logging, args.loglevel.upper(), None)
|
||||
if not isinstance(min_log_level, int):
|
||||
logging.error(f'Invalid log level: {args.loglevel.upper()}.')
|
||||
logging.error(f"Invalid log level: {args.loglevel.upper()}.")
|
||||
min_log_level = logging.WARNING
|
||||
|
||||
logging.basicConfig(
|
||||
filename=args.logfile,
|
||||
level=min_log_level,
|
||||
format='%(asctime)s:%(levelname)s:%(name)s:%(module)s:%(message)s',
|
||||
format="%(asctime)s:%(levelname)s:%(name)s:%(module)s:%(message)s",
|
||||
)
|
||||
|
||||
# Config File
|
||||
config_file = args.config
|
||||
if not config_file:
|
||||
# Default to ~/.config/sublime-music.
|
||||
config_file = Path(
|
||||
os.environ.get('XDG_CONFIG_HOME') or os.environ.get('APPDATA')
|
||||
or os.path.join('~/.config')).expanduser().joinpath(
|
||||
'sublime-music', 'config.yaml')
|
||||
config_file = (
|
||||
Path(
|
||||
os.environ.get("XDG_CONFIG_HOME")
|
||||
or os.environ.get("APPDATA")
|
||||
or os.path.join("~/.config")
|
||||
)
|
||||
.expanduser()
|
||||
.joinpath("sublime-music", "config.yaml")
|
||||
)
|
||||
|
||||
app = SublimeMusicApp(Path(config_file))
|
||||
app.run(unknown_args)
|
||||
|
@@ -1,15 +1,10 @@
|
||||
from .adapter_base import (
|
||||
Adapter,
|
||||
CacheMissError,
|
||||
CachingAdapter,
|
||||
ConfigParamDescriptor,
|
||||
)
|
||||
from .adapter_base import Adapter, CacheMissError, CachingAdapter, ConfigParamDescriptor
|
||||
from .adapter_manager import AdapterManager
|
||||
|
||||
__all__ = (
|
||||
'Adapter',
|
||||
'AdapterManager',
|
||||
'CacheMissError',
|
||||
'CachingAdapter',
|
||||
'ConfigParamDescriptor',
|
||||
"Adapter",
|
||||
"AdapterManager",
|
||||
"CacheMissError",
|
||||
"CachingAdapter",
|
||||
"ConfigParamDescriptor",
|
||||
)
|
||||
|
@@ -21,22 +21,21 @@ from .api_objects import (
|
||||
|
||||
class CacheMissError(Exception):
|
||||
"""
|
||||
This exception should be thrown by caching adapters when the request data
|
||||
is not available or is invalid. If some of the data is available, but not
|
||||
all of it, the ``partial_data`` parameter should be set with the partial
|
||||
data. If the ground truth adapter can't service the request, or errors for
|
||||
some reason, the UI will try to populate itself with the partial data
|
||||
returned in this exception (with the necessary error text to inform the
|
||||
user that retrieval from the ground truth adapter failed).
|
||||
This exception should be thrown by caching adapters when the request data is not
|
||||
available or is invalid. If some of the data is available, but not all of it, the
|
||||
``partial_data`` parameter should be set with the partial data. If the ground truth
|
||||
adapter can't service the request, or errors for some reason, the UI will try to
|
||||
populate itself with the partial data returned in this exception (with the necessary
|
||||
error text to inform the user that retrieval from the ground truth adapter failed).
|
||||
"""
|
||||
|
||||
def __init__(self, *args, partial_data: Any = None):
|
||||
"""
|
||||
Create a :class:`CacheMissError` exception.
|
||||
|
||||
:param args: arguments to pass to the :class:`BaseException` base
|
||||
class.
|
||||
:param partial_data: the actual partial data for the UI to use in case
|
||||
of ground truth adapter failure.
|
||||
:param args: arguments to pass to the :class:`BaseException` base class.
|
||||
:param partial_data: the actual partial data for the UI to use in case of ground
|
||||
truth adapter failure.
|
||||
"""
|
||||
self.partial_data = partial_data
|
||||
super().__init__(*args)
|
||||
@@ -46,31 +45,30 @@ class CacheMissError(Exception):
|
||||
class ConfigParamDescriptor:
|
||||
"""
|
||||
Describes a parameter that can be used to configure an adapter. The
|
||||
:class:`description`, :class:`required` and :class:`default:` should be
|
||||
self-evident as to what they do.
|
||||
:class:`description`, :class:`required` and :class:`default:` should be self-evident
|
||||
as to what they do.
|
||||
|
||||
The :class:`type` must be one of the following:
|
||||
|
||||
* The literal type ``str``: corresponds to a freeform text entry field in
|
||||
the UI.
|
||||
* The literal type ``str``: corresponds to a freeform text entry field in the UI.
|
||||
* The literal type ``bool``: corresponds to a checkbox in the UI.
|
||||
* The literal type ``int``: corresponds to a numeric input in the UI.
|
||||
* The literal string ``"password"``: corresponds to a password entry field
|
||||
in the UI.
|
||||
* The literal string ``"password"``: corresponds to a password entry field in the
|
||||
UI.
|
||||
* The literal string ``"option"``: corresponds to dropdown in the UI.
|
||||
|
||||
The :class:`numeric_bounds` parameter only has an effect if the
|
||||
:class:`type` is `int`. It specifies the min and max values that the UI
|
||||
control can have.
|
||||
The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
|
||||
`int`. It specifies the min and max values that the UI control can have.
|
||||
|
||||
The :class:`numeric_step` parameter only has an effect if the :class:`type`
|
||||
is `int`. It specifies the step that will be taken using the "+" and "-"
|
||||
buttons on the UI control (if supported).
|
||||
The :class:`numeric_step` parameter only has an effect if the :class:`type` is
|
||||
`int`. It specifies the step that will be taken using the "+" and "-" buttons on the
|
||||
UI control (if supported).
|
||||
|
||||
The :class:`options` parameter only has an effect if the :class:`type` is
|
||||
``"option"``. It specifies the list of options that will be available in
|
||||
the dropdown in the UI.
|
||||
``"option"``. It specifies the list of options that will be available in the
|
||||
dropdown in the UI.
|
||||
"""
|
||||
|
||||
type: Union[Type, str]
|
||||
description: str
|
||||
required: bool = True
|
||||
@@ -84,10 +82,11 @@ class Adapter(abc.ABC):
|
||||
"""
|
||||
Defines the interface for a Sublime Music Adapter.
|
||||
|
||||
All functions that actually retrieve data have a corresponding:
|
||||
``can_``-prefixed property (which can be dynamic) which specifies whether
|
||||
or not the adapter supports that operation at the moment.
|
||||
All functions that actually retrieve data have a corresponding: ``can_``-prefixed
|
||||
property (which can be dynamic) which specifies whether or not the adapter supports
|
||||
that operation at the moment.
|
||||
"""
|
||||
|
||||
# Configuration and Initialization Properties
|
||||
# These properties determine how the adapter can be configured and how to
|
||||
# initialize the adapter given those configuration values.
|
||||
@@ -98,58 +97,53 @@ class Adapter(abc.ABC):
|
||||
"""
|
||||
Specifies the settings which can be configured for the adapter.
|
||||
|
||||
:returns: An dictionary where the keys are the name of the
|
||||
configuration paramter and the values are the
|
||||
:class:`ConfigParamDescriptor` object corresponding to that
|
||||
configuration parameter. The order of the keys in the dictionary
|
||||
correspond to the order that the configuration parameters will be
|
||||
:returns: An dictionary where the keys are the name of the configuration
|
||||
paramter and the values are the :class:`ConfigParamDescriptor` object
|
||||
corresponding to that configuration parameter. The order of the keys in the
|
||||
dictionary correspond to the order that the configuration parameters will be
|
||||
shown in the UI.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def verify_configuration(
|
||||
config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Specifies a function for verifying whether or not the config is valid.
|
||||
|
||||
:param config: The adapter configuration. The keys of are the
|
||||
configuration parameter names as defined by the return value of the
|
||||
:class:`get_config_parameters` function. The values are the actual
|
||||
value of the configuration parameter. It is guaranteed that all
|
||||
configuration parameters that are marked as required will have a
|
||||
value in ``config``.
|
||||
:param config: The adapter configuration. The keys of are the configuration
|
||||
parameter names as defined by the return value of the
|
||||
:class:`get_config_parameters` function. The values are the actual value of
|
||||
the configuration parameter. It is guaranteed that all configuration
|
||||
parameters that are marked as required will have a value in ``config``.
|
||||
|
||||
:returns: A dictionary containing varification errors. The keys of the
|
||||
returned dictionary should be the same as the passed in via the
|
||||
``config`` parameter. The values should be strings describing why
|
||||
the corresponding value in the ``config`` dictionary is invalid.
|
||||
:returns: A dictionary containing varification errors. The keys of the returned
|
||||
dictionary should be the same as the passed in via the ``config`` parameter.
|
||||
The values should be strings describing why the corresponding value in the
|
||||
``config`` dictionary is invalid.
|
||||
|
||||
Not all keys need be returned (for example, if there's no error for
|
||||
a given configuration parameter), and returning `None` indicates no
|
||||
error.
|
||||
Not all keys need be returned (for example, if there's no error for a given
|
||||
configuration parameter), and returning `None` indicates no error.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, config: dict, data_directory: Path):
|
||||
"""
|
||||
This function should be overridden by inheritors of
|
||||
:class:`Adapter` and should be used to do whatever setup is
|
||||
required for the adapter.
|
||||
This function should be overridden by inheritors of :class:`Adapter` and should
|
||||
be used to do whatever setup is required for the adapter.
|
||||
|
||||
:param config: The adapter configuration. The keys of are the
|
||||
configuration parameter names as defined by the return value of the
|
||||
:class:`get_config_parameters` function. The values are the actual
|
||||
value of the configuration parameter.
|
||||
:param data_directory: the directory where the adapter can store data.
|
||||
This directory is guaranteed to exist.
|
||||
:param config: The adapter configuration. The keys of are the configuration
|
||||
parameter names as defined by the return value of the
|
||||
:class:`get_config_parameters` function. The values are the actual value of
|
||||
the configuration parameter.
|
||||
:param data_directory: the directory where the adapter can store data. This
|
||||
directory is guaranteed to exist.
|
||||
"""
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
This function is called when the app is being closed or the server is
|
||||
changing. This should be used to clean up anything that is necessary
|
||||
such as writing a cache to disk, disconnecting from a server, etc.
|
||||
This function is called when the app is being closed or the server is changing.
|
||||
This should be used to clean up anything that is necessary such as writing a
|
||||
cache to disk, disconnecting from a server, etc.
|
||||
"""
|
||||
|
||||
# Usage Properties
|
||||
@@ -159,11 +153,11 @@ class Adapter(abc.ABC):
|
||||
@property
|
||||
def can_be_cached(self) -> bool:
|
||||
"""
|
||||
Specifies whether or not this adapter can be used as the ground-truth
|
||||
adapter behind a caching adapter.
|
||||
Specifies whether or not this adapter can be used as the ground-truth adapter
|
||||
behind a caching adapter.
|
||||
|
||||
The default is ``True``, since most adapters will want to take
|
||||
advantage of the built-in filesystem cache.
|
||||
The default is ``True``, since most adapters will want to take advantage of the
|
||||
built-in filesystem cache.
|
||||
"""
|
||||
return True
|
||||
|
||||
@@ -175,13 +169,12 @@ class Adapter(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def can_service_requests(self) -> bool:
|
||||
"""
|
||||
Specifies whether or not the adapter can currently service requests. If
|
||||
this is ``False``, none of the other data retrieval functions are
|
||||
expected to work.
|
||||
Specifies whether or not the adapter can currently service requests. If this is
|
||||
``False``, none of the other data retrieval functions are expected to work.
|
||||
|
||||
For example, if your adapter requires access to an external service,
|
||||
use this function to determine if it is currently possible to connect
|
||||
to that external service.
|
||||
For example, if your adapter requires access to an external service, use this
|
||||
function to determine if it is currently possible to connect to that external
|
||||
service.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -194,8 +187,7 @@ class Adapter(abc.ABC):
|
||||
@property
|
||||
def can_get_playlist_details(self) -> bool:
|
||||
"""
|
||||
Whether :class:`get_playlist_details` can be called on the adapter
|
||||
right now.
|
||||
Whether :class:`get_playlist_details` can be called on the adapter right now.
|
||||
"""
|
||||
return False
|
||||
|
||||
@@ -208,86 +200,77 @@ class Adapter(abc.ABC):
|
||||
# =========================================================================
|
||||
def get_playlists(self) -> Sequence[Playlist]:
|
||||
"""
|
||||
Gets a list of all of the :class:`sublime.adapter.api_objects.Playlist`
|
||||
objects known to the adapter.
|
||||
Gets a list of all of the :class:`sublime.adapter.api_objects.Playlist` objects
|
||||
known to the adapter.
|
||||
"""
|
||||
raise self._check_can_error('get_playlists')
|
||||
raise self._check_can_error("get_playlists")
|
||||
|
||||
def get_playlist_details(
|
||||
self,
|
||||
playlist_id: str,
|
||||
) -> PlaylistDetails:
|
||||
def get_playlist_details(self, playlist_id: str,) -> PlaylistDetails:
|
||||
"""
|
||||
Gets the details about the given ``playlist_id``. If the playlist_id
|
||||
does not exist, then this function should throw an exception.
|
||||
Gets the details about the given ``playlist_id``. If the playlist_id does not
|
||||
exist, then this function should throw an exception.
|
||||
|
||||
:param playlist_id: The ID of the playlist to retrieve.
|
||||
"""
|
||||
raise self._check_can_error('get_playlist_details')
|
||||
raise self._check_can_error("get_playlist_details")
|
||||
|
||||
@staticmethod
|
||||
def _check_can_error(method_name: str) -> NotImplementedError:
|
||||
return NotImplementedError(
|
||||
f'Adapter.{method_name} called. '
|
||||
'Did you forget to check that can_{method_name} is True?')
|
||||
f"Adapter.{method_name} called. "
|
||||
"Did you forget to check that can_{method_name} is True?"
|
||||
)
|
||||
|
||||
|
||||
class CachingAdapter(Adapter):
|
||||
"""
|
||||
Defines an adapter that can be used as a cache for another adapter.
|
||||
|
||||
A caching adapter sits "in front" of a non-caching adapter and the UI
|
||||
will attempt to retrieve the data from the caching adapter before
|
||||
retrieving it from the non-caching adapter. (The exception is when the
|
||||
UI requests that the data come directly from the ground truth adapter,
|
||||
in which case the cache will be bypassed.)
|
||||
A caching adapter sits "in front" of a non-caching adapter and the UI will attempt
|
||||
to retrieve the data from the caching adapter before retrieving it from the
|
||||
non-caching adapter. (The exception is when the UI requests that the data come
|
||||
directly from the ground truth adapter, in which case the cache will be bypassed.)
|
||||
|
||||
Caching adapters *must* be able to service requests instantly, or
|
||||
nearly instantly (in most cases, this meanst the data must come
|
||||
directly from the local filesystem).
|
||||
Caching adapters *must* be able to service requests instantly, or nearly instantly
|
||||
(in most cases, this meanst the data must come directly from the local filesystem).
|
||||
"""
|
||||
@abc.abstractmethod
|
||||
def __init__(
|
||||
self,
|
||||
config: dict,
|
||||
data_directory: Path,
|
||||
is_cache: bool = False,
|
||||
):
|
||||
"""
|
||||
This function should be overridden by inheritors of
|
||||
:class:`CachingAdapter` and should be used to do whatever setup is
|
||||
required for the adapter.
|
||||
|
||||
:param config: The adapter configuration. The keys of are the
|
||||
configuration parameter names as defined by the return value of the
|
||||
:class:`get_config_parameters` function. The values are the actual
|
||||
value of the configuration parameter.
|
||||
:param data_directory: the directory where the adapter can store data.
|
||||
This directory is guaranteed to exist.
|
||||
@abc.abstractmethod
|
||||
def __init__(self, config: dict, data_directory: Path, is_cache: bool = False):
|
||||
"""
|
||||
This function should be overridden by inheritors of :class:`CachingAdapter` and
|
||||
should be used to do whatever setup is required for the adapter.
|
||||
|
||||
:param config: The adapter configuration. The keys of are the configuration
|
||||
parameter names as defined by the return value of the
|
||||
:class:`get_config_parameters` function. The values are the actual value of
|
||||
the configuration parameter.
|
||||
:param data_directory: the directory where the adapter can store data. This
|
||||
directory is guaranteed to exist.
|
||||
:param is_cache: whether or not the adapter is being used as a cache.
|
||||
"""
|
||||
|
||||
# Data Ingestion Methods
|
||||
# =========================================================================
|
||||
class FunctionNames(Enum):
|
||||
GET_PLAYLISTS = 'get_playlists'
|
||||
GET_PLAYLIST_DETAILS = 'get_playlist_details'
|
||||
GET_PLAYLISTS = "get_playlists"
|
||||
GET_PLAYLIST_DETAILS = "get_playlist_details"
|
||||
|
||||
@abc.abstractmethod
|
||||
def ingest_new_data(
|
||||
self,
|
||||
function: 'CachingAdapter.FunctionNames',
|
||||
function: "CachingAdapter.FunctionNames",
|
||||
params: Tuple[Any, ...],
|
||||
data: Any,
|
||||
):
|
||||
"""
|
||||
This function will be called after the fallback, ground-truth adapter
|
||||
returns new data. This normally will happen if this adapter has a cache
|
||||
miss or if the UI forces retrieval from the ground-truth adapter.
|
||||
This function will be called after the fallback, ground-truth adapter returns
|
||||
new data. This normally will happen if this adapter has a cache miss or if the
|
||||
UI forces retrieval from the ground-truth adapter.
|
||||
|
||||
:param function_name: the name of the function that was called on the
|
||||
ground truth adapter.
|
||||
:param params: the parameters that were passed to the function on the
|
||||
ground truth adapter.
|
||||
:param function_name: the name of the function that was called on the ground
|
||||
truth adapter.
|
||||
:param params: the parameters that were passed to the function on the ground
|
||||
truth adapter.
|
||||
:param data: the data that was returned by the ground truth adapter.
|
||||
"""
|
||||
|
@@ -21,15 +21,16 @@ from .api_objects import Playlist, PlaylistDetails
|
||||
from .filesystem import FilesystemAdapter
|
||||
from .subsonic import SubsonicAdapter
|
||||
|
||||
T = TypeVar('T')
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Result(Generic[T]):
|
||||
"""
|
||||
A result from a :class:`AdapterManager` function. This is effectively a
|
||||
wrapper around a :class:`concurrent.futures.Future`, but it can also
|
||||
resolve immediately if the data already exists.
|
||||
A result from a :class:`AdapterManager` function. This is effectively a wrapper
|
||||
around a :class:`concurrent.futures.Future`, but it can also resolve immediately if
|
||||
the data already exists.
|
||||
"""
|
||||
|
||||
_data: Optional[T] = None
|
||||
_future: Optional[Future] = None
|
||||
on_cancel: Optional[Callable[[], None]] = None
|
||||
@@ -51,8 +52,7 @@ class Result(Generic[T]):
|
||||
if self._future is not None:
|
||||
return self._future.result()
|
||||
|
||||
raise Exception(
|
||||
'AdapterManager.Result had neither _data nor _future member!')
|
||||
raise Exception("AdapterManager.Result had neither _data nor _future member!")
|
||||
|
||||
def add_done_callback(self, fn: Callable, *args):
|
||||
if self._future is not None:
|
||||
@@ -91,28 +91,28 @@ class AdapterManager:
|
||||
@staticmethod
|
||||
def register_adapter(adapter_class: Type):
|
||||
if not issubclass(adapter_class, Adapter):
|
||||
raise TypeError(
|
||||
'Attempting to register a class that is not an adapter.')
|
||||
raise TypeError("Attempting to register a class that is not an adapter.")
|
||||
AdapterManager.available_adapters.add(adapter_class)
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
This should not ever be called. You should only ever use the static
|
||||
methods on this class.
|
||||
This should not ever be called. You should only ever use the static methods on
|
||||
this class.
|
||||
"""
|
||||
raise Exception(
|
||||
"Cannot instantiate AdapterManager. Only use the static methods "
|
||||
"on the class.")
|
||||
"Do not instantiate the AdapterManager. "
|
||||
"Only use the static methods on the class."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def shutdown():
|
||||
logging.info('AdapterManager shutdown start')
|
||||
logging.info("AdapterManager shutdown start")
|
||||
AdapterManager.is_shutting_down = True
|
||||
AdapterManager.executor.shutdown()
|
||||
if AdapterManager._instance:
|
||||
AdapterManager._instance.shutdown()
|
||||
|
||||
logging.info('CacheManager shutdown complete')
|
||||
logging.info("CacheManager shutdown complete")
|
||||
|
||||
@staticmethod
|
||||
def reset(config: AppConfiguration):
|
||||
@@ -124,8 +124,8 @@ class AdapterManager:
|
||||
# to create, etc.
|
||||
assert config.server is not None
|
||||
source_data_dir = Path(config.cache_location, config.server.strhash())
|
||||
source_data_dir.joinpath('g').mkdir(parents=True, exist_ok=True)
|
||||
source_data_dir.joinpath('c').mkdir(parents=True, exist_ok=True)
|
||||
source_data_dir.joinpath("g").mkdir(parents=True, exist_ok=True)
|
||||
source_data_dir.joinpath("c").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ground_truth_adapter_type = SubsonicAdapter
|
||||
ground_truth_adapter = ground_truth_adapter_type(
|
||||
@@ -133,7 +133,7 @@ class AdapterManager:
|
||||
key: getattr(config.server, key)
|
||||
for key in ground_truth_adapter_type.get_config_parameters()
|
||||
},
|
||||
source_data_dir.joinpath('g'),
|
||||
source_data_dir.joinpath("g"),
|
||||
)
|
||||
|
||||
caching_adapter_type = FilesystemAdapter
|
||||
@@ -144,23 +144,22 @@ class AdapterManager:
|
||||
key: getattr(config.server, key)
|
||||
for key in caching_adapter_type.get_config_parameters()
|
||||
},
|
||||
source_data_dir.joinpath('c'),
|
||||
source_data_dir.joinpath("c"),
|
||||
is_cache=True,
|
||||
)
|
||||
|
||||
AdapterManager._instance = AdapterManager._AdapterManagerInternal(
|
||||
ground_truth_adapter,
|
||||
caching_adapter=caching_adapter,
|
||||
ground_truth_adapter, caching_adapter=caching_adapter,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_get_playlists() -> bool:
|
||||
# It only matters that the ground truth one can service the request.
|
||||
return (
|
||||
AdapterManager._instance is not None and
|
||||
AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||
and
|
||||
AdapterManager._instance.ground_truth_adapter.can_get_playlists)
|
||||
AdapterManager._instance is not None
|
||||
and AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||
and AdapterManager._instance.ground_truth_adapter.can_get_playlists
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_playlists(
|
||||
@@ -168,32 +167,31 @@ class AdapterManager:
|
||||
force: bool = False, # TODO: rename to use_ground_truth_adapter?
|
||||
) -> Result[Sequence[Playlist]]:
|
||||
assert AdapterManager._instance
|
||||
if (not force and AdapterManager._instance.caching_adapter and
|
||||
AdapterManager._instance.caching_adapter.can_service_requests
|
||||
and
|
||||
AdapterManager._instance.caching_adapter.can_get_playlists):
|
||||
if (
|
||||
not force
|
||||
and AdapterManager._instance.caching_adapter
|
||||
and AdapterManager._instance.caching_adapter.can_service_requests
|
||||
and AdapterManager._instance.caching_adapter.can_get_playlists
|
||||
):
|
||||
try:
|
||||
return Result(
|
||||
AdapterManager._instance.caching_adapter.get_playlists())
|
||||
return Result(AdapterManager._instance.caching_adapter.get_playlists())
|
||||
except CacheMissError:
|
||||
logging.debug(f'Cache Miss on {"get_playlists"}.')
|
||||
except Exception:
|
||||
logging.exception(
|
||||
f'Error on {"get_playlists"} retrieving from cache.')
|
||||
logging.exception(f'Error on {"get_playlists"} retrieving from cache.')
|
||||
|
||||
if (AdapterManager._instance.ground_truth_adapter
|
||||
and not AdapterManager._instance.ground_truth_adapter
|
||||
.can_service_requests and not AdapterManager._instance
|
||||
.ground_truth_adapter.can_get_playlists):
|
||||
raise Exception(
|
||||
f'No adapters can service {"get_playlists"} at the moment.')
|
||||
if (
|
||||
AdapterManager._instance.ground_truth_adapter
|
||||
and not AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||
and not AdapterManager._instance.ground_truth_adapter.can_get_playlists
|
||||
):
|
||||
raise Exception(f'No adapters can service {"get_playlists"} at the moment.')
|
||||
|
||||
def future_fn() -> Sequence[Playlist]:
|
||||
assert AdapterManager._instance
|
||||
if before_download:
|
||||
before_download()
|
||||
return (
|
||||
AdapterManager._instance.ground_truth_adapter.get_playlists())
|
||||
return AdapterManager._instance.ground_truth_adapter.get_playlists()
|
||||
|
||||
future: Result[Sequence[Playlist]] = Result(future_fn)
|
||||
|
||||
@@ -203,9 +201,7 @@ class AdapterManager:
|
||||
assert AdapterManager._instance
|
||||
assert AdapterManager._instance.caching_adapter
|
||||
AdapterManager._instance.caching_adapter.ingest_new_data(
|
||||
CachingAdapter.FunctionNames.GET_PLAYLISTS,
|
||||
(),
|
||||
f.result(),
|
||||
CachingAdapter.FunctionNames.GET_PLAYLISTS, (), f.result(),
|
||||
)
|
||||
|
||||
future.add_done_callback(future_finished)
|
||||
@@ -216,9 +212,10 @@ class AdapterManager:
|
||||
def can_get_playlist_details() -> bool:
|
||||
# It only matters that the ground truth one can service the request.
|
||||
return (
|
||||
AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||
and AdapterManager._instance.ground_truth_adapter
|
||||
.can_get_playlist_details)
|
||||
AdapterManager._instance is not None
|
||||
and AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||
and AdapterManager._instance.ground_truth_adapter.can_get_playlist_details
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_playlist_details(
|
||||
@@ -228,14 +225,18 @@ class AdapterManager:
|
||||
) -> Result[PlaylistDetails]:
|
||||
assert AdapterManager._instance
|
||||
partial_playlist_data = None
|
||||
if (not force and AdapterManager._instance.caching_adapter and
|
||||
AdapterManager._instance.caching_adapter.can_service_requests
|
||||
and AdapterManager._instance.caching_adapter
|
||||
.can_get_playlist_details):
|
||||
if (
|
||||
not force
|
||||
and AdapterManager._instance.caching_adapter
|
||||
and AdapterManager._instance.caching_adapter.can_service_requests
|
||||
and AdapterManager._instance.caching_adapter.can_get_playlist_details
|
||||
):
|
||||
try:
|
||||
return Result(
|
||||
AdapterManager._instance.caching_adapter
|
||||
.get_playlist_details(playlist_id))
|
||||
AdapterManager._instance.caching_adapter.get_playlist_details(
|
||||
playlist_id
|
||||
)
|
||||
)
|
||||
except CacheMissError as e:
|
||||
partial_playlist_data = e.partial_data
|
||||
logging.debug(f'Cache Miss on {"get_playlist_details"}.')
|
||||
@@ -244,10 +245,13 @@ class AdapterManager:
|
||||
f'Error on {"get_playlist_details"} retrieving from cache.'
|
||||
)
|
||||
|
||||
if (AdapterManager._instance.ground_truth_adapter
|
||||
and not AdapterManager._instance.ground_truth_adapter
|
||||
.can_service_requests and not AdapterManager._instance
|
||||
.ground_truth_adapter.can_get_playlist_details):
|
||||
if (
|
||||
AdapterManager._instance.ground_truth_adapter
|
||||
and not AdapterManager._instance.ground_truth_adapter.can_service_requests
|
||||
and not (
|
||||
AdapterManager._instance.ground_truth_adapter.can_get_playlist_details
|
||||
)
|
||||
):
|
||||
if partial_playlist_data:
|
||||
# TODO do something here
|
||||
pass
|
||||
@@ -259,9 +263,9 @@ class AdapterManager:
|
||||
assert AdapterManager._instance
|
||||
if before_download:
|
||||
before_download()
|
||||
return (
|
||||
AdapterManager._instance.ground_truth_adapter
|
||||
.get_playlist_details(playlist_id))
|
||||
return AdapterManager._instance.ground_truth_adapter.get_playlist_details(
|
||||
playlist_id
|
||||
)
|
||||
|
||||
future: Result[PlaylistDetails] = Result(future_fn)
|
||||
|
||||
@@ -272,7 +276,7 @@ class AdapterManager:
|
||||
assert AdapterManager._instance.caching_adapter
|
||||
AdapterManager._instance.caching_adapter.ingest_new_data(
|
||||
CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
|
||||
(playlist_id, ),
|
||||
(playlist_id,),
|
||||
f.result(),
|
||||
)
|
||||
|
||||
|
@@ -8,10 +8,10 @@ from typing import Optional, Sequence
|
||||
|
||||
|
||||
class MediaType(Enum):
|
||||
MUSIC = 'music'
|
||||
PODCAST = 'podcast'
|
||||
AUDIOBOOK = 'audiobook'
|
||||
VIDEO = 'video'
|
||||
MUSIC = "music"
|
||||
PODCAST = "podcast"
|
||||
AUDIOBOOK = "audiobook"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
class Song(abc.ABC):
|
||||
|
@@ -1,3 +1,3 @@
|
||||
from .adapter import FilesystemAdapter
|
||||
|
||||
__all__ = ('FilesystemAdapter', )
|
||||
__all__ = ("FilesystemAdapter",)
|
||||
|
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Tuple
|
||||
|
||||
from sublime.adapters.api_objects import (Playlist, PlaylistDetails)
|
||||
from sublime.adapters.api_objects import Playlist, PlaylistDetails
|
||||
|
||||
from . import models
|
||||
from .. import CacheMissError, CachingAdapter, ConfigParamDescriptor
|
||||
@@ -16,6 +16,7 @@ class FilesystemAdapter(CachingAdapter):
|
||||
"""
|
||||
Defines an adapter which retrieves its data from the local filesystem.
|
||||
"""
|
||||
|
||||
# Configuration and Initialization Properties
|
||||
# =========================================================================
|
||||
@staticmethod
|
||||
@@ -25,19 +26,15 @@ class FilesystemAdapter(CachingAdapter):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def verify_configuration(
|
||||
config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
return {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict,
|
||||
data_directory: Path,
|
||||
is_cache: bool = False,
|
||||
self, config: dict, data_directory: Path, is_cache: bool = False,
|
||||
):
|
||||
self.data_directory = data_directory
|
||||
self.is_cache = is_cache
|
||||
database_filename = data_directory.joinpath('cache.db')
|
||||
database_filename = data_directory.joinpath("cache.db")
|
||||
models.database.init(database_filename)
|
||||
models.database.connect()
|
||||
|
||||
@@ -45,7 +42,7 @@ class FilesystemAdapter(CachingAdapter):
|
||||
models.database.create_tables(models.ALL_TABLES)
|
||||
|
||||
def shutdown(self):
|
||||
logging.info('Shutdown complete')
|
||||
logging.info("Shutdown complete")
|
||||
|
||||
# Usage Properties
|
||||
# =========================================================================
|
||||
@@ -59,7 +56,7 @@ class FilesystemAdapter(CachingAdapter):
|
||||
# Data Helper Methods
|
||||
# =========================================================================
|
||||
def _params_hash(self, *params: Any) -> str:
|
||||
return hashlib.sha1(bytes(json.dumps(params), 'utf8')).hexdigest()
|
||||
return hashlib.sha1(bytes(json.dumps(params), "utf8")).hexdigest()
|
||||
|
||||
# Data Retrieval Methods
|
||||
# =========================================================================
|
||||
@@ -74,20 +71,17 @@ class FilesystemAdapter(CachingAdapter):
|
||||
# not, cache miss.
|
||||
function_name = CachingAdapter.FunctionNames.GET_PLAYLISTS
|
||||
if not models.CacheInfo.get_or_none(
|
||||
models.CacheInfo.query_name == function_name):
|
||||
models.CacheInfo.query_name == function_name
|
||||
):
|
||||
raise CacheMissError()
|
||||
return playlists
|
||||
|
||||
can_get_playlist_details: bool = True
|
||||
|
||||
def get_playlist_details(
|
||||
self,
|
||||
playlist_id: str,
|
||||
) -> PlaylistDetails:
|
||||
playlist = models.Playlist.get_or_none(
|
||||
models.Playlist.id == playlist_id)
|
||||
def get_playlist_details(self, playlist_id: str,) -> PlaylistDetails:
|
||||
playlist = models.Playlist.get_or_none(models.Playlist.id == playlist_id)
|
||||
if not playlist and not self.is_cache:
|
||||
raise Exception(f'Playlist {playlist_id} does not exist.')
|
||||
raise Exception(f"Playlist {playlist_id} does not exist.")
|
||||
|
||||
# If we haven't ingested data for this playlist before, raise a
|
||||
# CacheMissError with the partial playlist data.
|
||||
@@ -106,11 +100,11 @@ class FilesystemAdapter(CachingAdapter):
|
||||
@models.database.atomic()
|
||||
def ingest_new_data(
|
||||
self,
|
||||
function: 'CachingAdapter.FunctionNames',
|
||||
function: "CachingAdapter.FunctionNames",
|
||||
params: Tuple[Any, ...],
|
||||
data: Any,
|
||||
):
|
||||
assert self.is_cache, 'FilesystemAdapter is not in cache mode'
|
||||
assert self.is_cache, "FilesystemAdapter is not in cache mode"
|
||||
|
||||
models.CacheInfo.insert(
|
||||
query_name=function,
|
||||
@@ -119,24 +113,25 @@ class FilesystemAdapter(CachingAdapter):
|
||||
).on_conflict_replace().execute()
|
||||
|
||||
if function == CachingAdapter.FunctionNames.GET_PLAYLISTS:
|
||||
models.Playlist.insert_many(map(
|
||||
asdict, data)).on_conflict_replace().execute()
|
||||
models.Playlist.insert_many(
|
||||
map(asdict, data)
|
||||
).on_conflict_replace().execute()
|
||||
elif function == CachingAdapter.FunctionNames.GET_PLAYLIST_DETAILS:
|
||||
playlist_data = asdict(data)
|
||||
playlist, playlist_created = models.Playlist.get_or_create(
|
||||
id=playlist_data['id'],
|
||||
defaults=playlist_data,
|
||||
id=playlist_data["id"], defaults=playlist_data,
|
||||
)
|
||||
|
||||
# Handle the songs.
|
||||
songs = []
|
||||
for index, song_data in enumerate(playlist_data['songs']):
|
||||
for index, song_data in enumerate(playlist_data["songs"]):
|
||||
# args = dict(filter(lambda kv: kv[0] in f, song_data.items()))
|
||||
song_data['index'] = index
|
||||
song_data["index"] = index
|
||||
song, song_created = models.Song.get_or_create(
|
||||
id=song_data['id'], defaults=song_data)
|
||||
id=song_data["id"], defaults=song_data
|
||||
)
|
||||
|
||||
keys = ('title', 'duration', 'path', 'index')
|
||||
keys = ("title", "duration", "path", "index")
|
||||
if not song_created:
|
||||
for key in keys:
|
||||
setattr(song, key, song_data[key])
|
||||
@@ -145,7 +140,7 @@ class FilesystemAdapter(CachingAdapter):
|
||||
songs.append(song)
|
||||
|
||||
playlist.songs = songs
|
||||
del playlist_data['songs']
|
||||
del playlist_data["songs"]
|
||||
|
||||
# Update the values if the playlist already existed.
|
||||
if not playlist_created:
|
||||
|
@@ -70,7 +70,7 @@ class CacheInfo(BaseModel):
|
||||
last_ingestion_time = TzDateTimeField(null=False)
|
||||
|
||||
class Meta:
|
||||
primary_key = CompositeKey('query_name', 'params_hash')
|
||||
primary_key = CompositeKey("query_name", "params_hash")
|
||||
|
||||
|
||||
class Playlist(BaseModel):
|
||||
@@ -85,7 +85,7 @@ class Playlist(BaseModel):
|
||||
public = BooleanField(null=True)
|
||||
cover_art = TextField(null=True)
|
||||
|
||||
songs = SortedManyToManyField(Song, backref='playlists')
|
||||
songs = SortedManyToManyField(Song, backref="playlists")
|
||||
|
||||
|
||||
ALL_TABLES = (
|
||||
|
@@ -53,7 +53,7 @@ class SortedManyToManyQuery(ManyToManyQuery):
|
||||
accessor = self._accessor
|
||||
src_id = getattr(self._instance, self._src_attr)
|
||||
if isinstance(value, SelectQuery):
|
||||
print('TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT')
|
||||
print("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT")
|
||||
raise NotImplementedError()
|
||||
# query = value.columns(Value(src_id), accessor.dest_fk.rel_field)
|
||||
# accessor.through_model.insert_from(
|
||||
@@ -68,13 +68,14 @@ class SortedManyToManyQuery(ManyToManyQuery):
|
||||
{
|
||||
accessor.src_fk.name: src_id,
|
||||
accessor.dest_fk.name: rel_id,
|
||||
'position': i,
|
||||
} for i, rel_id in enumerate(self._id_list(value))
|
||||
"position": i,
|
||||
}
|
||||
for i, rel_id in enumerate(self._id_list(value))
|
||||
]
|
||||
accessor.through_model.insert_many(inserts).execute()
|
||||
|
||||
def remove(self, value: Any) -> Any:
|
||||
print('RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR')
|
||||
print("RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR")
|
||||
raise NotImplementedError()
|
||||
# src_id = getattr(self._instance, self._src_attr)
|
||||
# if isinstance(value, SelectQuery):
|
||||
@@ -102,23 +103,22 @@ class SortedManyToManyQuery(ManyToManyQuery):
|
||||
|
||||
class SortedManyToManyFieldAccessor(ManyToManyFieldAccessor):
|
||||
def __get__(
|
||||
self,
|
||||
instance: Model,
|
||||
instance_type: Any = None,
|
||||
force_query: bool = False,
|
||||
self, instance: Model, instance_type: Any = None, force_query: bool = False,
|
||||
):
|
||||
if instance is not None:
|
||||
if not force_query and self.src_fk.backref != '+':
|
||||
if not force_query and self.src_fk.backref != "+":
|
||||
backref = getattr(instance, self.src_fk.backref)
|
||||
if isinstance(backref, list):
|
||||
return [getattr(obj, self.dest_fk.name) for obj in backref]
|
||||
|
||||
src_id = getattr(instance, self.src_fk.rel_field.name)
|
||||
return SortedManyToManyQuery(instance, self, self.rel_model) \
|
||||
.join(self.through_model) \
|
||||
.join(self.model) \
|
||||
.where(self.src_fk == src_id) \
|
||||
return (
|
||||
SortedManyToManyQuery(instance, self, self.rel_model)
|
||||
.join(self.through_model)
|
||||
.join(self.model)
|
||||
.where(self.src_fk == src_id)
|
||||
.order_by(self.through_model.position)
|
||||
)
|
||||
|
||||
return self.field
|
||||
|
||||
@@ -137,16 +137,16 @@ class SortedManyToManyField(ManyToManyField):
|
||||
class Meta:
|
||||
database = self.model._meta.database
|
||||
schema = self.model._meta.schema
|
||||
table_name = '{}_{}_through'.format(*tables)
|
||||
indexes = (((lhs._meta.name, rhs._meta.name, 'position'), True), )
|
||||
table_name = "{}_{}_through".format(*tables)
|
||||
indexes = (((lhs._meta.name, rhs._meta.name, "position"), True),)
|
||||
|
||||
params = {'on_delete': self._on_delete, 'on_update': self._on_update}
|
||||
params = {"on_delete": self._on_delete, "on_update": self._on_update}
|
||||
attrs = {
|
||||
lhs._meta.name: ForeignKeyField(lhs, **params),
|
||||
rhs._meta.name: ForeignKeyField(rhs, **params),
|
||||
'position': IntegerField(),
|
||||
'Meta': Meta
|
||||
"position": IntegerField(),
|
||||
"Meta": Meta,
|
||||
}
|
||||
|
||||
klass_name = '{}{}Through'.format(lhs.__name__, rhs.__name__)
|
||||
return type(klass_name, (Model, ), attrs)
|
||||
klass_name = "{}{}Through".format(lhs.__name__, rhs.__name__)
|
||||
return type(klass_name, (Model,), attrs)
|
||||
|
@@ -1,3 +1,3 @@
|
||||
from .adapter import SubsonicAdapter
|
||||
|
||||
__all__ = ('SubsonicAdapter', )
|
||||
__all__ = ("SubsonicAdapter",)
|
||||
|
@@ -16,34 +16,30 @@ class SubsonicAdapter(Adapter):
|
||||
"""
|
||||
Defines an adapter which retrieves its data from a Subsonic server
|
||||
"""
|
||||
|
||||
# Configuration and Initialization Properties
|
||||
# =========================================================================
|
||||
@staticmethod
|
||||
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
|
||||
return {
|
||||
'server_address':
|
||||
ConfigParamDescriptor(str, 'Server address'),
|
||||
'username':
|
||||
ConfigParamDescriptor(str, 'Username'),
|
||||
'password':
|
||||
ConfigParamDescriptor('password', 'Password'),
|
||||
'disable_cert_verify':
|
||||
ConfigParamDescriptor('password', 'Password', False),
|
||||
"server_address": ConfigParamDescriptor(str, "Server address"),
|
||||
"username": ConfigParamDescriptor(str, "Username"),
|
||||
"password": ConfigParamDescriptor("password", "Password"),
|
||||
"disable_cert_verify": ConfigParamDescriptor("password", "Password", False),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def verify_configuration(
|
||||
config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||
errors: Dict[str, Optional[str]] = {}
|
||||
|
||||
# TODO: verify the URL
|
||||
return errors
|
||||
|
||||
def __init__(self, config: dict, data_directory: Path):
|
||||
self.hostname = config['server_address']
|
||||
self.username = config['username']
|
||||
self.password = config['password']
|
||||
self.disable_cert_verify = config.get('disable_cert_verify')
|
||||
self.hostname = config["server_address"]
|
||||
self.username = config["username"]
|
||||
self.password = config["password"]
|
||||
self.disable_cert_verify = config.get("disable_cert_verify")
|
||||
|
||||
# TODO support XML | JSON
|
||||
|
||||
@@ -53,44 +49,45 @@ class SubsonicAdapter(Adapter):
|
||||
def can_service_requests(self) -> bool:
|
||||
try:
|
||||
# Try to ping the server with a timeout of 2 seconds.
|
||||
self._get_json(self._make_url('ping'), timeout=2)
|
||||
self._get_json(self._make_url("ping"), timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception(f'Could not connect to {self.hostname}')
|
||||
logging.exception(f"Could not connect to {self.hostname}")
|
||||
return False
|
||||
|
||||
# Helper mothods for making requests
|
||||
# =========================================================================
|
||||
def _get_params(self) -> Dict[str, str]:
|
||||
"""
|
||||
Gets the parameters that are needed for all requests to the Subsonic
|
||||
API. See Subsonic API Introduction for details.
|
||||
Gets the parameters that are needed for all requests to the Subsonic API. See
|
||||
Subsonic API Introduction for details.
|
||||
"""
|
||||
return {
|
||||
'u': self.username,
|
||||
'p': self.password,
|
||||
'c': 'Sublime Music',
|
||||
'f': 'json',
|
||||
'v': '1.15.0',
|
||||
"u": self.username,
|
||||
"p": self.password,
|
||||
"c": "Sublime Music",
|
||||
"f": "json",
|
||||
"v": "1.15.0",
|
||||
}
|
||||
|
||||
def _make_url(self, endpoint: str) -> str:
|
||||
return f'{self.hostname}/rest/{endpoint}.view'
|
||||
return f"{self.hostname}/rest/{endpoint}.view"
|
||||
|
||||
def _get(
|
||||
self,
|
||||
url: str,
|
||||
timeout: Union[float, Tuple[float, float], None] = None,
|
||||
**params,
|
||||
self,
|
||||
url: str,
|
||||
timeout: Union[float, Tuple[float, float], None] = None,
|
||||
**params,
|
||||
) -> Any:
|
||||
params = {**self._get_params(), **params}
|
||||
logging.info(f'[START] get: {url}')
|
||||
logging.info(f"[START] get: {url}")
|
||||
|
||||
if os.environ.get('SUBLIME_MUSIC_DEBUG_DELAY'):
|
||||
if os.environ.get("SUBLIME_MUSIC_DEBUG_DELAY"):
|
||||
logging.info(
|
||||
"SUBLIME_MUSIC_DEBUG_DELAY enabled. Pausing for "
|
||||
f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds.")
|
||||
sleep(float(os.environ['SUBLIME_MUSIC_DEBUG_DELAY']))
|
||||
f"{os.environ['SUBLIME_MUSIC_DEBUG_DELAY']} seconds."
|
||||
)
|
||||
sleep(float(os.environ["SUBLIME_MUSIC_DEBUG_DELAY"]))
|
||||
|
||||
# Deal with datetime parameters (convert to milliseconds since 1970)
|
||||
for k, v in params.items():
|
||||
@@ -98,21 +95,18 @@ class SubsonicAdapter(Adapter):
|
||||
params[k] = int(v.timestamp() * 1000)
|
||||
|
||||
if self._is_mock:
|
||||
logging.debug('Using mock data')
|
||||
logging.debug("Using mock data")
|
||||
return self._get_mock_data()
|
||||
|
||||
result = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
verify=not self.disable_cert_verify,
|
||||
timeout=timeout,
|
||||
url, params=params, verify=not self.disable_cert_verify, timeout=timeout
|
||||
)
|
||||
|
||||
# TODO (#122): make better
|
||||
if result.status_code != 200:
|
||||
raise Exception(f'[FAIL] get: {url} status={result.status_code}')
|
||||
raise Exception(f"[FAIL] get: {url} status={result.status_code}")
|
||||
|
||||
logging.info(f'[FINISH] get: {url}')
|
||||
logging.info(f"[FINISH] get: {url}")
|
||||
return result
|
||||
|
||||
def _get_json(
|
||||
@@ -122,27 +116,27 @@ class SubsonicAdapter(Adapter):
|
||||
**params: Union[None, str, datetime, int, Sequence[int]],
|
||||
) -> Response:
|
||||
"""
|
||||
Make a get request to a *Sonic REST API. Handle all types of errors
|
||||
including *Sonic ``<error>`` responses.
|
||||
Make a get request to a *Sonic REST API. Handle all types of errors including
|
||||
*Sonic ``<error>`` responses.
|
||||
|
||||
:returns: a dictionary of the subsonic response.
|
||||
:raises Exception: needs some work TODO
|
||||
"""
|
||||
result = self._get(url, timeout=timeout, **params)
|
||||
subsonic_response = result.json().get('subsonic-response')
|
||||
subsonic_response = result.json().get("subsonic-response")
|
||||
|
||||
# TODO (#122): make better
|
||||
if not subsonic_response:
|
||||
raise Exception(f'[FAIL] get: invalid JSON from {url}')
|
||||
raise Exception(f"[FAIL] get: invalid JSON from {url}")
|
||||
|
||||
if subsonic_response['status'] == 'failed':
|
||||
if subsonic_response["status"] == "failed":
|
||||
code, message = (
|
||||
subsonic_response['error'].get('code'),
|
||||
subsonic_response['error'].get('message'),
|
||||
subsonic_response["error"].get("code"),
|
||||
subsonic_response["error"].get("message"),
|
||||
)
|
||||
raise Exception(f'Subsonic API Error #{code}: {message}')
|
||||
raise Exception(f"Subsonic API Error #{code}: {message}")
|
||||
|
||||
logging.debug(f'Response from {url}', subsonic_response)
|
||||
logging.debug(f"Response from {url}", subsonic_response)
|
||||
return Response.from_dict(subsonic_response)
|
||||
|
||||
# Helper Methods for Testing
|
||||
@@ -172,21 +166,15 @@ class SubsonicAdapter(Adapter):
|
||||
can_get_playlists = True
|
||||
|
||||
def get_playlists(self) -> Sequence[API.Playlist]:
|
||||
response = self._get_json(self._make_url('getPlaylists')).playlists
|
||||
response = self._get_json(self._make_url("getPlaylists")).playlists
|
||||
if not response:
|
||||
return []
|
||||
return response.playlist
|
||||
|
||||
can_get_playlist_details = True
|
||||
|
||||
def get_playlist_details(
|
||||
self,
|
||||
playlist_id: str,
|
||||
) -> API.PlaylistDetails:
|
||||
result = self._get_json(
|
||||
self._make_url('getPlaylist'),
|
||||
id=playlist_id,
|
||||
).playlist
|
||||
def get_playlist_details(self, playlist_id: str,) -> API.PlaylistDetails:
|
||||
result = self._get_json(self._make_url("getPlaylist"), id=playlist_id,).playlist
|
||||
# TODO better error
|
||||
assert result, f'Error getting playlist {playlist_id}'
|
||||
assert result, f"Error getting playlist {playlist_id}"
|
||||
return result
|
||||
|
@@ -18,15 +18,13 @@ from .. import api_objects as SublimeAPI
|
||||
|
||||
# Translation map
|
||||
extra_translation_map = {
|
||||
datetime:
|
||||
(lambda s: datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%f%z') if s else None),
|
||||
datetime: (lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%f%z") if s else None),
|
||||
timedelta: (lambda s: timedelta(seconds=s) if s else None),
|
||||
}
|
||||
|
||||
for type_, translation_function in extra_translation_map.items():
|
||||
dataclasses_json.cfg.global_config.decoders[type_] = translation_function
|
||||
dataclasses_json.cfg.global_config.decoders[
|
||||
Optional[type_]] = translation_function
|
||||
dataclasses_json.cfg.global_config.decoders[Optional[type_]] = translation_function
|
||||
|
||||
|
||||
@dataclass_json(letter_case=LetterCase.CAMEL)
|
||||
@@ -81,8 +79,7 @@ class Playlist(SublimeAPI.Playlist):
|
||||
class PlaylistWithSongs(SublimeAPI.PlaylistDetails):
|
||||
id: str
|
||||
name: str
|
||||
songs: List[Song] = field(
|
||||
default_factory=list, metadata=config(field_name='entry'))
|
||||
songs: List[Song] = field(default_factory=list, metadata=config(field_name="entry"))
|
||||
song_count: int = field(default=0)
|
||||
duration: timedelta = field(default=timedelta())
|
||||
created: Optional[datetime] = None
|
||||
@@ -96,8 +93,9 @@ class PlaylistWithSongs(SublimeAPI.PlaylistDetails):
|
||||
self.song_count = self.song_count or len(self.songs)
|
||||
self.duration = self.duration or timedelta(
|
||||
seconds=sum(
|
||||
s.duration.total_seconds() if s.duration else 0
|
||||
for s in self.songs))
|
||||
s.duration.total_seconds() if s.duration else 0 for s in self.songs
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -107,9 +105,8 @@ class Playlists(DataClassJsonMixin):
|
||||
|
||||
@dataclass
|
||||
class Response(DataClassJsonMixin):
|
||||
"""
|
||||
The base Subsonic response object.
|
||||
"""
|
||||
"""The base Subsonic response object."""
|
||||
|
||||
song: Optional[Song] = None
|
||||
playlists: Optional[Playlists] = None
|
||||
playlist: Optional[PlaylistWithSongs] = None
|
||||
|
518
sublime/app.py
518
sublime/app.py
@@ -9,22 +9,27 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import osxmmkeys
|
||||
|
||||
tap_imported = True
|
||||
except Exception:
|
||||
tap_imported = False
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
|
||||
|
||||
try:
|
||||
gi.require_version('Notify', '0.7')
|
||||
gi.require_version("Notify", "0.7")
|
||||
from gi.repository import Notify
|
||||
|
||||
glib_notify_exists = True
|
||||
except Exception:
|
||||
# I really don't care what kind of exception it is, all that matters is the
|
||||
# import failed for some reason.
|
||||
logging.warning(
|
||||
'Unable to import Notify from GLib. Notifications will be disabled.')
|
||||
"Unable to import Notify from GLib. Notifications will be disabled."
|
||||
)
|
||||
glib_notify_exists = False
|
||||
|
||||
from .adapters import AdapterManager
|
||||
@@ -44,14 +49,14 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def __init__(self, config_file: Path):
|
||||
super().__init__(application_id="com.sumnerevans.sublimemusic")
|
||||
if glib_notify_exists:
|
||||
Notify.init('Sublime Music')
|
||||
Notify.init("Sublime Music")
|
||||
|
||||
self.window: Optional[Gtk.Window] = None
|
||||
self.app_config = AppConfiguration.load_from_file(config_file)
|
||||
self.player = None
|
||||
self.dbus_manager: Optional[DBusManager] = None
|
||||
|
||||
self.connect('shutdown', self.on_app_shutdown)
|
||||
self.connect("shutdown", self.on_app_shutdown)
|
||||
|
||||
def do_startup(self):
|
||||
Gtk.Application.do_startup(self)
|
||||
@@ -61,40 +66,39 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if type(parameter_type) == str:
|
||||
parameter_type = GLib.VariantType(parameter_type)
|
||||
action = Gio.SimpleAction.new(name, parameter_type)
|
||||
action.connect('activate', fn)
|
||||
action.connect("activate", fn)
|
||||
self.add_action(action)
|
||||
|
||||
# Add action for menu items.
|
||||
add_action('configure-servers', self.on_configure_servers)
|
||||
add_action('settings', self.on_settings)
|
||||
add_action("configure-servers", self.on_configure_servers)
|
||||
add_action("settings", self.on_settings)
|
||||
|
||||
# Add actions for player controls
|
||||
add_action('play-pause', self.on_play_pause)
|
||||
add_action('next-track', self.on_next_track)
|
||||
add_action('prev-track', self.on_prev_track)
|
||||
add_action('repeat-press', self.on_repeat_press)
|
||||
add_action('shuffle-press', self.on_shuffle_press)
|
||||
add_action("play-pause", self.on_play_pause)
|
||||
add_action("next-track", self.on_next_track)
|
||||
add_action("prev-track", self.on_prev_track)
|
||||
add_action("repeat-press", self.on_repeat_press)
|
||||
add_action("shuffle-press", self.on_shuffle_press)
|
||||
|
||||
# Navigation actions.
|
||||
add_action('play-next', self.on_play_next, parameter_type='as')
|
||||
add_action('add-to-queue', self.on_add_to_queue, parameter_type='as')
|
||||
add_action('go-to-album', self.on_go_to_album, parameter_type='s')
|
||||
add_action('go-to-artist', self.on_go_to_artist, parameter_type='s')
|
||||
add_action('browse-to', self.browse_to, parameter_type='s')
|
||||
add_action(
|
||||
'go-to-playlist', self.on_go_to_playlist, parameter_type='s')
|
||||
add_action("play-next", self.on_play_next, parameter_type="as")
|
||||
add_action("add-to-queue", self.on_add_to_queue, parameter_type="as")
|
||||
add_action("go-to-album", self.on_go_to_album, parameter_type="s")
|
||||
add_action("go-to-artist", self.on_go_to_artist, parameter_type="s")
|
||||
add_action("browse-to", self.browse_to, parameter_type="s")
|
||||
add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
|
||||
|
||||
add_action('mute-toggle', self.on_mute_toggle)
|
||||
add_action("mute-toggle", self.on_mute_toggle)
|
||||
add_action(
|
||||
'update-play-queue-from-server',
|
||||
"update-play-queue-from-server",
|
||||
lambda a, p: self.update_play_state_from_server(),
|
||||
)
|
||||
|
||||
if tap_imported:
|
||||
self.tap = osxmmkeys.Tap()
|
||||
self.tap.on('play_pause', self.on_play_pause)
|
||||
self.tap.on('next_track', self.on_next_track)
|
||||
self.tap.on('prev_track', self.on_prev_track)
|
||||
self.tap.on("play_pause", self.on_play_pause)
|
||||
self.tap.on("next_track", self.on_next_track)
|
||||
self.tap.on("prev_track", self.on_prev_track)
|
||||
self.tap.start()
|
||||
|
||||
def do_activate(self):
|
||||
@@ -111,26 +115,25 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# window.
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_path(
|
||||
os.path.join(os.path.dirname(__file__), 'ui/app_styles.css'))
|
||||
os.path.join(os.path.dirname(__file__), "ui/app_styles.css")
|
||||
)
|
||||
context = Gtk.StyleContext()
|
||||
screen = Gdk.Screen.get_default()
|
||||
context.add_provider_for_screen(
|
||||
screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
|
||||
screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
|
||||
)
|
||||
|
||||
self.window.stack.connect(
|
||||
'notify::visible-child',
|
||||
self.on_stack_change,
|
||||
"notify::visible-child", self.on_stack_change,
|
||||
)
|
||||
self.window.connect('song-clicked', self.on_song_clicked)
|
||||
self.window.connect('songs-removed', self.on_songs_removed)
|
||||
self.window.connect('refresh-window', self.on_refresh_window)
|
||||
self.window.connect('go-to', self.on_window_go_to)
|
||||
self.window.connect('key-press-event', self.on_window_key_press)
|
||||
self.window.player_controls.connect('song-scrub', self.on_song_scrub)
|
||||
self.window.player_controls.connect(
|
||||
'device-update', self.on_device_update)
|
||||
self.window.player_controls.connect(
|
||||
'volume-change', self.on_volume_change)
|
||||
self.window.connect("song-clicked", self.on_song_clicked)
|
||||
self.window.connect("songs-removed", self.on_songs_removed)
|
||||
self.window.connect("refresh-window", self.on_refresh_window)
|
||||
self.window.connect("go-to", self.on_window_go_to)
|
||||
self.window.connect("key-press-event", self.on_window_key_press)
|
||||
self.window.player_controls.connect("song-scrub", self.on_song_scrub)
|
||||
self.window.player_controls.connect("device-update", self.on_device_update)
|
||||
self.window.player_controls.connect("volume-change", self.on_volume_change)
|
||||
|
||||
self.window.show_all()
|
||||
self.window.present()
|
||||
@@ -155,8 +158,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.should_scrobble_song = False
|
||||
|
||||
def time_observer(value: Optional[float]):
|
||||
if (self.loading_state or not self.window
|
||||
or not self.app_config.state.current_song):
|
||||
if (
|
||||
self.loading_state
|
||||
or not self.window
|
||||
or not self.app_config.state.current_song
|
||||
):
|
||||
return
|
||||
|
||||
if value is None:
|
||||
@@ -178,10 +184,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.should_scrobble_song = False
|
||||
|
||||
def on_track_end():
|
||||
at_end = self.app_config.state.current_song_index == len(
|
||||
self.app_config.state.play_queue) - 1
|
||||
no_repeat = (
|
||||
self.app_config.state.repeat_type == RepeatType.NO_REPEAT)
|
||||
at_end = (
|
||||
self.app_config.state.current_song_index
|
||||
== len(self.app_config.state.play_queue) - 1
|
||||
)
|
||||
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
||||
if at_end and no_repeat:
|
||||
self.app_config.state.playing = False
|
||||
self.app_config.state.current_song_index = -1
|
||||
@@ -192,32 +199,26 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
@dbus_propagate(self)
|
||||
def on_player_event(event: PlayerEvent):
|
||||
if event.name == 'play_state_change':
|
||||
if event.name == "play_state_change":
|
||||
self.app_config.state.playing = event.value
|
||||
elif event.name == 'volume_change':
|
||||
elif event.name == "volume_change":
|
||||
self.app_config.state.volume = event.value
|
||||
|
||||
GLib.idle_add(self.update_window)
|
||||
|
||||
self.mpv_player = MPVPlayer(
|
||||
time_observer,
|
||||
on_track_end,
|
||||
on_player_event,
|
||||
self.app_config,
|
||||
time_observer, on_track_end, on_player_event, self.app_config,
|
||||
)
|
||||
self.chromecast_player = ChromecastPlayer(
|
||||
time_observer,
|
||||
on_track_end,
|
||||
on_player_event,
|
||||
self.app_config,
|
||||
time_observer, on_track_end, on_player_event, self.app_config,
|
||||
)
|
||||
self.player = self.mpv_player
|
||||
|
||||
if self.app_config.state.current_device != 'this device':
|
||||
if self.app_config.state.current_device != "this device":
|
||||
# TODO (#120)
|
||||
pass
|
||||
|
||||
self.app_config.state.current_device = 'this device'
|
||||
self.app_config.state.current_device = "this device"
|
||||
|
||||
# Need to do this after we set the current device.
|
||||
self.player.volume = self.app_config.state.volume
|
||||
@@ -231,8 +232,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.dbus_manager.property_diff()
|
||||
|
||||
# ########## DBUS MANAGMENT ########## #
|
||||
def do_dbus_register(
|
||||
self, connection: Gio.DBusConnection, path: str) -> bool:
|
||||
def do_dbus_register(self, connection: Gio.DBusConnection, path: str) -> bool:
|
||||
self.dbus_manager = DBusManager(
|
||||
connection,
|
||||
self.on_dbus_method_call,
|
||||
@@ -264,9 +264,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# it could be a directory.
|
||||
assert self.app_config.state.current_song.duration is not None
|
||||
self.on_song_scrub(
|
||||
None,
|
||||
new_seconds / self.app_config.state.current_song.duration
|
||||
* 100,
|
||||
None, new_seconds / self.app_config.state.current_song.duration * 100,
|
||||
)
|
||||
|
||||
def set_pos_fn(track_id: str, position: float = 0):
|
||||
@@ -274,12 +272,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.on_play_pause()
|
||||
pos_seconds = position / second_microsecond_conversion
|
||||
self.app_config.state.song_progress = pos_seconds
|
||||
track_id, occurrence = track_id.split('/')[-2:]
|
||||
track_id, occurrence = track_id.split("/")[-2:]
|
||||
|
||||
# Find the (N-1)th time that the track id shows up in the list. (N
|
||||
# is the -*** suffix on the track id.)
|
||||
song_index = [
|
||||
i for i, x in enumerate(self.app_config.state.play_queue)
|
||||
i
|
||||
for i, x in enumerate(self.app_config.state.play_queue)
|
||||
if x == track_id
|
||||
][int(occurrence) or 0]
|
||||
|
||||
@@ -291,42 +290,42 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
if len(track_ids) == 0:
|
||||
# We are lucky, just return an empty list.
|
||||
return GLib.Variant('(aa{sv})', ([], ))
|
||||
return GLib.Variant("(aa{sv})", ([],))
|
||||
|
||||
# Have to calculate all of the metadatas so that we can deal with
|
||||
# repeat song IDs.
|
||||
metadatas: Iterable[Any] = [
|
||||
self.dbus_manager.get_mpris_metadata(
|
||||
i,
|
||||
self.app_config.state.play_queue,
|
||||
) for i in range(len(self.app_config.state.play_queue))
|
||||
i, self.app_config.state.play_queue,
|
||||
)
|
||||
for i in range(len(self.app_config.state.play_queue))
|
||||
]
|
||||
|
||||
# Get rid of all of the tracks that were not requested.
|
||||
metadatas = list(
|
||||
filter(lambda m: m['mpris:trackid'] in track_ids, metadatas))
|
||||
filter(lambda m: m["mpris:trackid"] in track_ids, metadatas)
|
||||
)
|
||||
|
||||
assert len(metadatas) == len(track_ids)
|
||||
|
||||
# Sort them so they get returned in the same order as they were
|
||||
# requested.
|
||||
metadatas = sorted(
|
||||
metadatas, key=lambda m: track_ids.index(m['mpris:trackid']))
|
||||
metadatas, key=lambda m: track_ids.index(m["mpris:trackid"])
|
||||
)
|
||||
|
||||
# Turn them into dictionaries that can actually be serialized into
|
||||
# a GLib.Variant.
|
||||
metadatas = map(
|
||||
lambda m: {k: DBusManager.to_variant(v)
|
||||
for k, v in m.items()},
|
||||
lambda m: {k: DBusManager.to_variant(v) for k, v in m.items()},
|
||||
metadatas,
|
||||
)
|
||||
|
||||
return GLib.Variant('(aa{sv})', (list(metadatas), ))
|
||||
return GLib.Variant("(aa{sv})", (list(metadatas),))
|
||||
|
||||
def activate_playlist(playlist_id: str):
|
||||
playlist_id = playlist_id.split('/')[-1]
|
||||
playlist = AdapterManager.get_playlist_details(
|
||||
playlist_id).result()
|
||||
playlist_id = playlist_id.split("/")[-1]
|
||||
playlist = AdapterManager.get_playlist_details(playlist_id).result()
|
||||
|
||||
# Calculate the song id to play.
|
||||
song_idx = 0
|
||||
@@ -337,46 +336,44 @@ class SublimeMusicApp(Gtk.Application):
|
||||
None,
|
||||
song_idx,
|
||||
[s.id for s in playlist.songs],
|
||||
{'active_playlist_id': playlist_id},
|
||||
{"active_playlist_id": playlist_id},
|
||||
)
|
||||
|
||||
def get_playlists(
|
||||
index: int,
|
||||
max_count: int,
|
||||
order: str,
|
||||
reverse_order: bool,
|
||||
index: int, max_count: int, order: str, reverse_order: bool,
|
||||
) -> GLib.Variant:
|
||||
playlists_result = AdapterManager.get_playlists()
|
||||
if not playlists_result.data_is_available:
|
||||
# We don't want to wait for the response in this case, so just
|
||||
# return an empty array.
|
||||
return GLib.Variant('(a(oss))', ([], ))
|
||||
return GLib.Variant("(a(oss))", ([],))
|
||||
|
||||
playlists = list(playlists_result.result())
|
||||
|
||||
sorters = {
|
||||
'Alphabetical': lambda p: p.name,
|
||||
'Created': lambda p: p.created,
|
||||
'Modified': lambda p: p.changed,
|
||||
"Alphabetical": lambda p: p.name,
|
||||
"Created": lambda p: p.created,
|
||||
"Modified": lambda p: p.changed,
|
||||
}
|
||||
playlists.sort(
|
||||
key=sorters.get(order, lambda p: p),
|
||||
reverse=reverse_order,
|
||||
key=sorters.get(order, lambda p: p), reverse=reverse_order,
|
||||
)
|
||||
|
||||
def make_playlist_tuple(p: Playlist) -> GLib.Variant:
|
||||
cover_art_filename = CacheManager.get_cover_art_filename(
|
||||
p.cover_art,
|
||||
allow_download=False,
|
||||
p.cover_art, allow_download=False,
|
||||
).result()
|
||||
return (f'/playlist/{p.id}', p.name, cover_art_filename or '')
|
||||
return (f"/playlist/{p.id}", p.name, cover_art_filename or "")
|
||||
|
||||
return GLib.Variant(
|
||||
'(a(oss))', (
|
||||
"(a(oss))",
|
||||
(
|
||||
[
|
||||
make_playlist_tuple(p)
|
||||
for p in playlists[index:(index + max_count)]
|
||||
], ))
|
||||
for p in playlists[index : (index + max_count)]
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
def play():
|
||||
if not self.app_config.state.playing:
|
||||
@@ -387,36 +384,34 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.on_play_pause()
|
||||
|
||||
method_call_map: Dict[str, Dict[str, Any]] = {
|
||||
'org.mpris.MediaPlayer2': {
|
||||
'Raise': self.window and self.window.present,
|
||||
'Quit': self.window and self.window.destroy,
|
||||
"org.mpris.MediaPlayer2": {
|
||||
"Raise": self.window and self.window.present,
|
||||
"Quit": self.window and self.window.destroy,
|
||||
},
|
||||
'org.mpris.MediaPlayer2.Player': {
|
||||
'Next': self.on_next_track,
|
||||
'Previous': self.on_prev_track,
|
||||
'Pause': pause,
|
||||
'PlayPause': self.on_play_pause,
|
||||
'Stop': pause,
|
||||
'Play': play,
|
||||
'Seek': seek_fn,
|
||||
'SetPosition': set_pos_fn,
|
||||
"org.mpris.MediaPlayer2.Player": {
|
||||
"Next": self.on_next_track,
|
||||
"Previous": self.on_prev_track,
|
||||
"Pause": pause,
|
||||
"PlayPause": self.on_play_pause,
|
||||
"Stop": pause,
|
||||
"Play": play,
|
||||
"Seek": seek_fn,
|
||||
"SetPosition": set_pos_fn,
|
||||
},
|
||||
'org.mpris.MediaPlayer2.TrackList': {
|
||||
'GoTo': set_pos_fn,
|
||||
'GetTracksMetadata': get_tracks_metadata,
|
||||
"org.mpris.MediaPlayer2.TrackList": {
|
||||
"GoTo": set_pos_fn,
|
||||
"GetTracksMetadata": get_tracks_metadata,
|
||||
# 'RemoveTrack': remove_track,
|
||||
},
|
||||
'org.mpris.MediaPlayer2.Playlists': {
|
||||
'ActivatePlaylist': activate_playlist,
|
||||
'GetPlaylists': get_playlists,
|
||||
"org.mpris.MediaPlayer2.Playlists": {
|
||||
"ActivatePlaylist": activate_playlist,
|
||||
"GetPlaylists": get_playlists,
|
||||
},
|
||||
}
|
||||
method_fn = method_call_map.get(interface, {}).get(method)
|
||||
if method_fn is None:
|
||||
logging.warning(
|
||||
f'Unknown/unimplemented method: {interface}.{method}.')
|
||||
invocation.return_value(
|
||||
method_fn(*params) if callable(method_fn) else None)
|
||||
logging.warning(f"Unknown/unimplemented method: {interface}.{method}.")
|
||||
invocation.return_value(method_fn(*params) if callable(method_fn) else None)
|
||||
|
||||
def on_dbus_set_property(
|
||||
self,
|
||||
@@ -428,9 +423,9 @@ class SublimeMusicApp(Gtk.Application):
|
||||
value: GLib.Variant,
|
||||
):
|
||||
def change_loop(new_loop_status: GLib.Variant):
|
||||
self.app_config.state.repeat_type = (
|
||||
RepeatType.from_mpris_loop_status(
|
||||
new_loop_status.get_string()))
|
||||
self.app_config.state.repeat_type = RepeatType.from_mpris_loop_status(
|
||||
new_loop_status.get_string()
|
||||
)
|
||||
self.update_window()
|
||||
|
||||
def set_shuffle(new_val: GLib.Variant):
|
||||
@@ -441,17 +436,17 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.on_volume_change(None, new_val.get_double() * 100)
|
||||
|
||||
setter_map: Dict[str, Dict[str, Any]] = {
|
||||
'org.mpris.MediaPlayer2.Player': {
|
||||
'LoopStatus': change_loop,
|
||||
'Rate': lambda _: None,
|
||||
'Shuffle': set_shuffle,
|
||||
'Volume': set_volume,
|
||||
"org.mpris.MediaPlayer2.Player": {
|
||||
"LoopStatus": change_loop,
|
||||
"Rate": lambda _: None,
|
||||
"Shuffle": set_shuffle,
|
||||
"Volume": set_volume,
|
||||
}
|
||||
}
|
||||
|
||||
setter = setter_map.get(interface, {}).get(property_name)
|
||||
if setter is None:
|
||||
logging.warning('Set: Unknown property: {property_name}.')
|
||||
logging.warning("Set: Unknown property: {property_name}.")
|
||||
return
|
||||
if callable(setter):
|
||||
setter(value)
|
||||
@@ -459,10 +454,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# ########## ACTION HANDLERS ########## #
|
||||
@dbus_propagate()
|
||||
def on_refresh_window(
|
||||
self,
|
||||
_,
|
||||
state_updates: Dict[str, Any],
|
||||
force: bool = False,
|
||||
self, _, state_updates: Dict[str, Any], force: bool = False,
|
||||
):
|
||||
for k, v in state_updates.items():
|
||||
setattr(self.app_config.state, k, v)
|
||||
@@ -476,32 +468,34 @@ class SublimeMusicApp(Gtk.Application):
|
||||
dialog = SettingsDialog(self.window, self.app_config)
|
||||
result = dialog.run()
|
||||
if result == Gtk.ResponseType.OK:
|
||||
self.app_config.port_number = int(
|
||||
dialog.data['port_number'].get_text())
|
||||
self.app_config.always_stream = dialog.data[
|
||||
'always_stream'].get_active()
|
||||
self.app_config.port_number = int(dialog.data["port_number"].get_text())
|
||||
self.app_config.always_stream = dialog.data["always_stream"].get_active()
|
||||
self.app_config.download_on_stream = dialog.data[
|
||||
'download_on_stream'].get_active()
|
||||
"download_on_stream"
|
||||
].get_active()
|
||||
self.app_config.song_play_notification = dialog.data[
|
||||
'song_play_notification'].get_active()
|
||||
self.app_config.serve_over_lan = dialog.data[
|
||||
'serve_over_lan'].get_active()
|
||||
"song_play_notification"
|
||||
].get_active()
|
||||
self.app_config.serve_over_lan = dialog.data["serve_over_lan"].get_active()
|
||||
self.app_config.prefetch_amount = dialog.data[
|
||||
'prefetch_amount'].get_value_as_int()
|
||||
"prefetch_amount"
|
||||
].get_value_as_int()
|
||||
self.app_config.concurrent_download_limit = dialog.data[
|
||||
'concurrent_download_limit'].get_value_as_int()
|
||||
"concurrent_download_limit"
|
||||
].get_value_as_int()
|
||||
self.app_config.replay_gain = ReplayGainType.from_string(
|
||||
dialog.data['replay_gain'].get_active_id())
|
||||
dialog.data["replay_gain"].get_active_id()
|
||||
)
|
||||
self.app_config.save()
|
||||
self.reset_state()
|
||||
dialog.destroy()
|
||||
|
||||
def on_window_go_to(self, win: Any, action: str, value: str):
|
||||
{
|
||||
'album': self.on_go_to_album,
|
||||
'artist': self.on_go_to_artist,
|
||||
'playlist': self.on_go_to_playlist,
|
||||
}[action](None, GLib.Variant('s', value))
|
||||
"album": self.on_go_to_album,
|
||||
"artist": self.on_go_to_artist,
|
||||
"playlist": self.on_go_to_playlist,
|
||||
}[action](None, GLib.Variant("s", value))
|
||||
|
||||
@dbus_propagate()
|
||||
def on_play_pause(self, *args):
|
||||
@@ -523,8 +517,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
||||
song_index_to_play = self.app_config.state.current_song_index
|
||||
# Wrap around the play queue if at the end.
|
||||
elif self.app_config.state.current_song_index == len(
|
||||
self.app_config.state.play_queue) - 1:
|
||||
elif (
|
||||
self.app_config.state.current_song_index
|
||||
== len(self.app_config.state.play_queue) - 1
|
||||
):
|
||||
# This may happen due to D-Bus.
|
||||
if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
|
||||
return
|
||||
@@ -545,8 +541,8 @@ class SublimeMusicApp(Gtk.Application):
|
||||
song_index_to_play = 0
|
||||
else:
|
||||
song_index_to_play = (
|
||||
self.app_config.state.current_song_index - 1) % len(
|
||||
self.app_config.state.play_queue)
|
||||
self.app_config.state.current_song_index - 1
|
||||
) % len(self.app_config.state.play_queue)
|
||||
else:
|
||||
# Go back to the beginning of the song.
|
||||
song_index_to_play = self.app_config.state.current_song_index
|
||||
@@ -556,8 +552,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
@dbus_propagate()
|
||||
def on_repeat_press(self, *args):
|
||||
# Cycle through the repeat types.
|
||||
new_repeat_type = RepeatType(
|
||||
(self.app_config.state.repeat_type.value + 1) % 3)
|
||||
new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
|
||||
self.app_config.state.repeat_type = new_repeat_type
|
||||
self.update_window()
|
||||
|
||||
@@ -565,22 +560,26 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def on_shuffle_press(self, *args):
|
||||
if self.app_config.state.shuffle_on:
|
||||
# Revert to the old play queue.
|
||||
self.app_config.state.current_song_index = (
|
||||
self.app_config.state.old_play_queue.index(
|
||||
self.app_config.state.current_song.id))
|
||||
self.app_config.state.current_song_index = self.app_config.state.old_play_queue.index(
|
||||
self.app_config.state.current_song.id
|
||||
)
|
||||
self.app_config.state.play_queue = (
|
||||
self.app_config.state.old_play_queue.copy())
|
||||
self.app_config.state.old_play_queue.copy()
|
||||
)
|
||||
else:
|
||||
self.app_config.state.old_play_queue = (
|
||||
self.app_config.state.play_queue.copy())
|
||||
self.app_config.state.play_queue.copy()
|
||||
)
|
||||
|
||||
# Remove the current song, then shuffle and put the song back.
|
||||
song_id = self.app_config.state.current_song.id
|
||||
del self.app_config.state.play_queue[
|
||||
self.app_config.state.current_song_index]
|
||||
self.app_config.state.current_song_index
|
||||
]
|
||||
random.shuffle(self.app_config.state.play_queue)
|
||||
self.app_config.state.play_queue = (
|
||||
[song_id] + self.app_config.state.play_queue)
|
||||
self.app_config.state.play_queue = [
|
||||
song_id
|
||||
] + self.app_config.state.play_queue
|
||||
self.app_config.state.current_song_index = 0
|
||||
|
||||
self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on
|
||||
@@ -594,8 +593,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
insert_at = self.app_config.state.current_song_index + 1
|
||||
|
||||
self.app_config.state.play_queue = (
|
||||
self.app_config.state.play_queue[:insert_at] + list(song_ids)
|
||||
+ self.app_config.state.play_queue[insert_at:])
|
||||
self.app_config.state.play_queue[:insert_at]
|
||||
+ list(song_ids)
|
||||
+ self.app_config.state.play_queue[insert_at:]
|
||||
)
|
||||
self.app_config.state.old_play_queue.extend(song_ids)
|
||||
self.update_window()
|
||||
|
||||
@@ -613,43 +614,43 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if len(album.child) > 0:
|
||||
album = album.child[0]
|
||||
|
||||
if album.get('year'):
|
||||
self.app_config.state.current_album_sort = 'byYear'
|
||||
self.app_config.state.current_album_from_year = album.year
|
||||
self.app_config.state.current_album_to_year = album.year
|
||||
elif album.get('genre'):
|
||||
self.app_config.state.current_album_sort = 'byGenre'
|
||||
self.app_config.state.current_album_genre = album.genre
|
||||
if year := album.get("year"):
|
||||
self.app_config.state.current_album_sort = "byYear"
|
||||
self.app_config.state.current_album_from_year = year
|
||||
self.app_config.state.current_album_to_year = year
|
||||
elif genre := album.get("genre"):
|
||||
self.app_config.state.current_album_sort = "byGenre"
|
||||
self.app_config.state.current_album_genre = genre
|
||||
else:
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self.window,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text='Could not go to album',
|
||||
text="Could not go to album",
|
||||
)
|
||||
dialog.format_secondary_markup(
|
||||
'Could not go to the album because it does not have a year or '
|
||||
'genre.')
|
||||
"Could not go to the album because it does not have a year or " "genre."
|
||||
)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
return
|
||||
|
||||
self.app_config.state.current_tab = 'albums'
|
||||
self.app_config.state.current_tab = "albums"
|
||||
self.app_config.state.selected_album_id = album_id.get_string()
|
||||
self.update_window(force=True)
|
||||
|
||||
def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
|
||||
self.app_config.state.current_tab = 'artists'
|
||||
self.app_config.state.current_tab = "artists"
|
||||
self.app_config.state.selected_artist_id = artist_id.get_string()
|
||||
self.update_window()
|
||||
|
||||
def browse_to(self, action: Any, item_id: GLib.Variant):
|
||||
self.app_config.state.current_tab = 'browse'
|
||||
self.app_config.state.current_tab = "browse"
|
||||
self.app_config.state.selected_browse_element_id = item_id.get_string()
|
||||
self.update_window()
|
||||
|
||||
def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant):
|
||||
self.app_config.state.current_tab = 'playlists'
|
||||
self.app_config.state.current_tab = "playlists"
|
||||
self.app_config.state.selected_playlist_id = playlist_id.get_string()
|
||||
self.update_window()
|
||||
|
||||
@@ -673,9 +674,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.save()
|
||||
|
||||
def on_connected_server_changed(
|
||||
self,
|
||||
action: Any,
|
||||
current_server_index: int,
|
||||
self, action: Any, current_server_index: int,
|
||||
):
|
||||
if self.app_config.server:
|
||||
self.app_config.save()
|
||||
@@ -710,18 +709,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# previous one.
|
||||
old_play_queue = song_queue.copy()
|
||||
|
||||
if metadata.get('force_shuffle_state') is not None:
|
||||
self.app_config.state.shuffle_on = metadata['force_shuffle_state']
|
||||
if (force_shuffle := metadata.get("force_shuffle_state")) is not None:
|
||||
self.app_config.state.shuffle_on = force_shuffle
|
||||
|
||||
if metadata.get('active_playlist_id') is not None:
|
||||
self.app_config.state.active_playlist_id = metadata.get(
|
||||
'active_playlist_id')
|
||||
else:
|
||||
self.app_config.state.active_playlist_id = None
|
||||
self.app_config.state.active_playlist_id = metadata.get("active_playlist_id")
|
||||
|
||||
# If shuffle is enabled, then shuffle the playlist.
|
||||
if self.app_config.state.shuffle_on and not metadata.get(
|
||||
'no_reshuffle'):
|
||||
if self.app_config.state.shuffle_on and not metadata.get("no_reshuffle"):
|
||||
song_id = song_queue[song_index]
|
||||
|
||||
del song_queue[song_index]
|
||||
@@ -746,7 +740,8 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# Determine how many songs before the currently playing one were also
|
||||
# deleted.
|
||||
before_current = [
|
||||
i for i in song_indexes_to_remove
|
||||
i
|
||||
for i in song_indexes_to_remove
|
||||
if i < self.app_config.state.current_song_index
|
||||
]
|
||||
|
||||
@@ -759,8 +754,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
self.app_config.state.current_song_index -= len(before_current)
|
||||
self.play_song(
|
||||
self.app_config.state.current_song_index,
|
||||
reset=True,
|
||||
self.app_config.state.current_song_index, reset=True,
|
||||
)
|
||||
else:
|
||||
self.app_config.state.current_song_index -= len(before_current)
|
||||
@@ -776,8 +770,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# a duration, but the Child object has `duration` optional because
|
||||
# it could be a directory.
|
||||
assert self.app_config.state.current_song.duration is not None
|
||||
new_time = self.app_config.state.current_song.duration * (
|
||||
scrub_value / 100)
|
||||
new_time = self.app_config.state.current_song.duration * (scrub_value / 100)
|
||||
|
||||
self.app_config.state.song_progress = new_time
|
||||
self.window.player_controls.update_scrubber(
|
||||
@@ -806,7 +799,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
self.update_window()
|
||||
|
||||
if device_uuid == 'this device':
|
||||
if device_uuid == "this device":
|
||||
self.player = self.mpv_player
|
||||
else:
|
||||
self.chromecast_player.set_playing_chromecast(device_uuid)
|
||||
@@ -829,14 +822,9 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.player.volume = self.app_config.state.volume
|
||||
self.update_window()
|
||||
|
||||
def on_window_key_press(
|
||||
self,
|
||||
window: Gtk.Window,
|
||||
event: Gdk.EventKey,
|
||||
) -> bool:
|
||||
def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey,) -> bool:
|
||||
# Need to use bitwise & here to see if CTRL is pressed.
|
||||
if (event.keyval == 102
|
||||
and event.state & Gdk.ModifierType.CONTROL_MASK):
|
||||
if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
|
||||
# Ctrl + F
|
||||
window.search_entry.grab_focus()
|
||||
return False
|
||||
@@ -857,7 +845,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
return False
|
||||
|
||||
def on_app_shutdown(self, app: 'SublimeMusicApp'):
|
||||
def on_app_shutdown(self, app: "SublimeMusicApp"):
|
||||
if glib_notify_exists:
|
||||
Notify.uninit()
|
||||
|
||||
@@ -882,9 +870,8 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def show_configure_servers_dialog(self):
|
||||
"""Show the Connect to Server dialog."""
|
||||
dialog = ConfigureServersDialog(self.window, self.app_config)
|
||||
dialog.connect('server-list-changed', self.on_server_list_changed)
|
||||
dialog.connect(
|
||||
'connected-server-changed', self.on_connected_server_changed)
|
||||
dialog.connect("server-list-changed", self.on_server_list_changed)
|
||||
dialog.connect("connected-server-changed", self.on_connected_server_changed)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
@@ -912,11 +899,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
progress_diff = 15
|
||||
if self.app_config.state.song_progress:
|
||||
progress_diff = abs(
|
||||
self.app_config.state.song_progress
|
||||
- new_song_progress)
|
||||
self.app_config.state.song_progress - new_song_progress
|
||||
)
|
||||
|
||||
if (self.app_config.state.play_queue == new_play_queue
|
||||
and self.app_config.state.current_song):
|
||||
if (
|
||||
self.app_config.state.play_queue == new_play_queue
|
||||
and self.app_config.state.current_song
|
||||
):
|
||||
song_id = self.app_config.state.current_song.id
|
||||
if song_id == new_current_song_id and progress_diff < 15:
|
||||
return
|
||||
@@ -925,14 +914,18 @@ class SublimeMusicApp(Gtk.Application):
|
||||
transient_for=self.window,
|
||||
message_type=Gtk.MessageType.INFO,
|
||||
buttons=Gtk.ButtonsType.YES_NO,
|
||||
text='Resume Playback?',
|
||||
text="Resume Playback?",
|
||||
)
|
||||
|
||||
dialog.format_secondary_markup(
|
||||
'Do you want to resume the play queue saved by '
|
||||
+ str(play_queue.changedBy) + ' at '
|
||||
+ play_queue.changed.astimezone(
|
||||
tz=None).strftime('%H:%M on %Y-%m-%d') + '?')
|
||||
"Do you want to resume the play queue saved by "
|
||||
+ str(play_queue.changedBy)
|
||||
+ " at "
|
||||
+ play_queue.changed.astimezone(tz=None).strftime(
|
||||
"%H:%M on %Y-%m-%d"
|
||||
)
|
||||
+ "?"
|
||||
)
|
||||
result = dialog.run()
|
||||
dialog.destroy()
|
||||
if result != Gtk.ResponseType.YES:
|
||||
@@ -941,8 +934,9 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.play_queue = new_play_queue
|
||||
self.app_config.state.song_progress = play_queue.position / 1000
|
||||
|
||||
self.app_config.state.current_song_index = (
|
||||
self.app_config.state.play_queue.index(new_current_song_id))
|
||||
self.app_config.state.current_song_index = self.app_config.state.play_queue.index(
|
||||
new_current_song_id
|
||||
)
|
||||
|
||||
self.player.reset()
|
||||
self.update_window()
|
||||
@@ -951,8 +945,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.on_play_pause()
|
||||
|
||||
play_queue_future = CacheManager.get_play_queue()
|
||||
play_queue_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(do_update, f))
|
||||
play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f))
|
||||
|
||||
song_playing_order_token = 0
|
||||
|
||||
@@ -968,8 +961,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
@dbus_propagate(self)
|
||||
def do_play_song(song: Child):
|
||||
uri, stream = CacheManager.get_song_filename_or_stream(
|
||||
song,
|
||||
force_stream=self.app_config.always_stream,
|
||||
song, force_stream=self.app_config.always_stream,
|
||||
)
|
||||
# Prevent it from doing the thing where it continually loads
|
||||
# songs when it has to download.
|
||||
@@ -984,24 +976,21 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if glib_notify_exists:
|
||||
notification_lines = []
|
||||
if song.album:
|
||||
notification_lines.append(f'<i>{song.album}</i>')
|
||||
notification_lines.append(f"<i>{song.album}</i>")
|
||||
if song.artist:
|
||||
notification_lines.append(song.artist)
|
||||
song_notification = Notify.Notification.new(
|
||||
song.title,
|
||||
'\n'.join(notification_lines),
|
||||
song.title, "\n".join(notification_lines),
|
||||
)
|
||||
song_notification.add_action(
|
||||
'clicked',
|
||||
'Open Sublime Music',
|
||||
lambda *a: self.window.present()
|
||||
if self.window else None,
|
||||
"clicked",
|
||||
"Open Sublime Music",
|
||||
lambda *a: self.window.present() if self.window else None,
|
||||
)
|
||||
song_notification.show()
|
||||
|
||||
def on_cover_art_download_complete(
|
||||
cover_art_filename: str,
|
||||
order_token: int,
|
||||
cover_art_filename: str, order_token: int,
|
||||
):
|
||||
if order_token != self.song_playing_order_token:
|
||||
return
|
||||
@@ -1010,51 +999,54 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# the notification.
|
||||
song_notification.set_image_from_pixbuf(
|
||||
GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
cover_art_filename, 70, 70, True))
|
||||
cover_art_filename, 70, 70, True
|
||||
)
|
||||
)
|
||||
song_notification.show()
|
||||
|
||||
def get_cover_art_filename(
|
||||
order_token: int) -> Tuple[str, int]:
|
||||
def get_cover_art_filename(order_token: int) -> Tuple[str, int]:
|
||||
return (
|
||||
CacheManager.get_cover_art_filename(
|
||||
song.coverArt).result(),
|
||||
song.coverArt
|
||||
).result(),
|
||||
order_token,
|
||||
)
|
||||
|
||||
self.song_playing_order_token += 1
|
||||
cover_art_future = CacheManager.create_future(
|
||||
get_cover_art_filename,
|
||||
self.song_playing_order_token,
|
||||
get_cover_art_filename, self.song_playing_order_token,
|
||||
)
|
||||
cover_art_future.add_done_callback(
|
||||
lambda f: on_cover_art_download_complete(
|
||||
*f.result()))
|
||||
if sys.platform == 'darwin':
|
||||
lambda f: on_cover_art_download_complete(*f.result())
|
||||
)
|
||||
if sys.platform == "darwin":
|
||||
notification_lines = []
|
||||
if song.album:
|
||||
notification_lines.append(song.album)
|
||||
if song.artist:
|
||||
notification_lines.append(song.artist)
|
||||
notification_text = '\n'.join(notification_lines)
|
||||
notification_text = "\n".join(notification_lines)
|
||||
osascript_command = [
|
||||
'display',
|
||||
'notification',
|
||||
"display",
|
||||
"notification",
|
||||
f'"{notification_text}"',
|
||||
'with',
|
||||
'title',
|
||||
"with",
|
||||
"title",
|
||||
f'"{song.title}"',
|
||||
]
|
||||
|
||||
os.system(
|
||||
f"osascript -e '{' '.join(osascript_command)}'")
|
||||
os.system(f"osascript -e '{' '.join(osascript_command)}'")
|
||||
except Exception:
|
||||
logging.warning(
|
||||
'Unable to display notification. Is a notification '
|
||||
'daemon running?')
|
||||
"Unable to display notification. Is a notification "
|
||||
"daemon running?"
|
||||
)
|
||||
|
||||
def on_song_download_complete(song_id: int):
|
||||
if (self.app_config.state.current_song
|
||||
and self.app_config.state.current_song.id != song.id):
|
||||
if (
|
||||
self.app_config.state.current_song
|
||||
and self.app_config.state.current_song.id != song.id
|
||||
):
|
||||
return
|
||||
if not self.app_config.state.playing:
|
||||
return
|
||||
@@ -1071,8 +1063,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
# If streaming, also download the song, unless configured not to,
|
||||
# or configured to always stream.
|
||||
if (stream and self.app_config.download_on_stream
|
||||
and not self.app_config.always_stream):
|
||||
if (
|
||||
stream
|
||||
and self.app_config.download_on_stream
|
||||
and not self.app_config.always_stream
|
||||
):
|
||||
CacheManager.batch_download_songs(
|
||||
[song.id],
|
||||
before_download=lambda: self.update_window(),
|
||||
@@ -1080,9 +1075,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
)
|
||||
|
||||
self.player.play_media(
|
||||
uri,
|
||||
0 if reset else self.app_config.state.song_progress,
|
||||
song,
|
||||
uri, 0 if reset else self.app_config.state.song_progress, song,
|
||||
)
|
||||
self.app_config.state.playing = True
|
||||
self.update_window()
|
||||
@@ -1096,17 +1089,16 @@ class SublimeMusicApp(Gtk.Application):
|
||||
for i in range(self.app_config.prefetch_amount):
|
||||
prefetch_idx: int = song_idx + 1 + i
|
||||
play_queue_len: int = len(self.app_config.state.play_queue)
|
||||
if (is_repeat_queue or prefetch_idx < play_queue_len):
|
||||
if is_repeat_queue or prefetch_idx < play_queue_len:
|
||||
prefetch_idxs.append(
|
||||
prefetch_idx % play_queue_len) # noqa: S001
|
||||
prefetch_idx % play_queue_len
|
||||
) # noqa: S001
|
||||
CacheManager.batch_download_songs(
|
||||
[
|
||||
self.app_config.state.play_queue[i]
|
||||
for i in prefetch_idxs
|
||||
],
|
||||
[self.app_config.state.play_queue[i] for i in prefetch_idxs],
|
||||
before_download=lambda: GLib.idle_add(self.update_window),
|
||||
on_song_download_complete=lambda _: GLib.idle_add(
|
||||
self.update_window),
|
||||
self.update_window
|
||||
),
|
||||
)
|
||||
|
||||
if old_play_queue:
|
||||
@@ -1121,10 +1113,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.save_play_queue()
|
||||
|
||||
song_details_future = CacheManager.get_song_details(
|
||||
self.app_config.state.play_queue[
|
||||
self.app_config.state.current_song_index])
|
||||
self.app_config.state.play_queue[self.app_config.state.current_song_index]
|
||||
)
|
||||
song_details_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(do_play_song, f.result()), )
|
||||
lambda f: GLib.idle_add(do_play_song, f.result()),
|
||||
)
|
||||
|
||||
def save_play_queue(self):
|
||||
if len(self.app_config.state.play_queue) == 0:
|
||||
@@ -1133,8 +1126,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
position = self.app_config.state.song_progress
|
||||
self.last_play_queue_update = position or 0
|
||||
|
||||
if (self.app_config.server.sync_enabled
|
||||
and self.app_config.state.current_song):
|
||||
if self.app_config.server.sync_enabled and self.app_config.state.current_song:
|
||||
CacheManager.save_play_queue(
|
||||
play_queue=self.app_config.state.play_queue,
|
||||
current=self.app_config.state.current_song.id,
|
||||
|
@@ -34,14 +34,17 @@ from fuzzywuzzy import fuzz
|
||||
|
||||
try:
|
||||
import gi
|
||||
gi.require_version('NM', '1.0')
|
||||
|
||||
gi.require_version("NM", "1.0")
|
||||
from gi.repository import NM
|
||||
|
||||
networkmanager_imported = True
|
||||
except Exception:
|
||||
# I really don't care what kind of exception it is, all that matters is the
|
||||
# import failed for some reason.
|
||||
logging.warning(
|
||||
'Unable to import NM from GLib. Detection of SSID will be disabled.')
|
||||
"Unable to import NM from GLib. Detection of SSID will be disabled."
|
||||
)
|
||||
networkmanager_imported = False
|
||||
|
||||
from .config import AppConfiguration
|
||||
@@ -67,6 +70,7 @@ class Singleton(type):
|
||||
Metaclass for :class:`CacheManager` so that it can be used like a
|
||||
singleton.
|
||||
"""
|
||||
|
||||
def __getattr__(cls, name: str) -> Any:
|
||||
if not CacheManager._instance:
|
||||
return None
|
||||
@@ -103,7 +107,7 @@ def similarity_ratio(query: str, string: str) -> int:
|
||||
return fuzz.partial_ratio(query.lower(), string.lower())
|
||||
|
||||
|
||||
S = TypeVar('S')
|
||||
S = TypeVar("S")
|
||||
|
||||
|
||||
class SearchResult:
|
||||
@@ -111,6 +115,7 @@ class SearchResult:
|
||||
An object representing the aggregate results of a search which can include
|
||||
both server and local results.
|
||||
"""
|
||||
|
||||
_artist: Set[ArtistID3] = set()
|
||||
_album: Set[AlbumID3] = set()
|
||||
_song: Set[Child] = set()
|
||||
@@ -124,21 +129,15 @@ class SearchResult:
|
||||
if results is None:
|
||||
return
|
||||
|
||||
member = f'_{result_type}'
|
||||
member = f"_{result_type}"
|
||||
if getattr(self, member) is None:
|
||||
setattr(self, member, set())
|
||||
|
||||
setattr(
|
||||
self,
|
||||
member,
|
||||
getattr(getattr(self, member, set()), 'union')(set(results)),
|
||||
self, member, getattr(getattr(self, member, set()), "union")(set(results)),
|
||||
)
|
||||
|
||||
def _to_result(
|
||||
self,
|
||||
it: Iterable[S],
|
||||
transform: Callable[[S], str],
|
||||
) -> List[S]:
|
||||
def _to_result(self, it: Iterable[S], transform: Callable[[S], str],) -> List[S]:
|
||||
all_results = sorted(
|
||||
((similarity_ratio(self.query, transform(x)), x) for x in it),
|
||||
key=lambda rx: rx[0],
|
||||
@@ -164,13 +163,13 @@ class SearchResult:
|
||||
if self._album is None:
|
||||
return None
|
||||
|
||||
return self._to_result(self._album, lambda a: f'{a.name} - {a.artist}')
|
||||
return self._to_result(self._album, lambda a: f"{a.name} - {a.artist}")
|
||||
|
||||
@property
|
||||
def song(self) -> Optional[List[Child]]:
|
||||
if self._song is None:
|
||||
return None
|
||||
return self._to_result(self._song, lambda s: f'{s.title} - {s.artist}')
|
||||
return self._to_result(self._song, lambda s: f"{s.title} - {s.artist}")
|
||||
|
||||
@property
|
||||
def playlist(self) -> Optional[List[Playlist]]:
|
||||
@@ -179,13 +178,14 @@ class SearchResult:
|
||||
return self._to_result(self._playlist, lambda p: p.name)
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class CacheManager(metaclass=Singleton):
|
||||
"""
|
||||
Handles everything related to caching metadata and song files.
|
||||
"""
|
||||
|
||||
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50)
|
||||
should_exit: bool = False
|
||||
|
||||
@@ -205,8 +205,8 @@ class CacheManager(metaclass=Singleton):
|
||||
on_cancel: Optional[Callable[[], None]] = None
|
||||
|
||||
@staticmethod
|
||||
def from_data(data: T) -> 'CacheManager.Result[T]':
|
||||
result: 'CacheManager.Result[T]' = CacheManager.Result()
|
||||
def from_data(data: T) -> "CacheManager.Result[T]":
|
||||
result: "CacheManager.Result[T]" = CacheManager.Result()
|
||||
result.data = data
|
||||
return result
|
||||
|
||||
@@ -216,8 +216,8 @@ class CacheManager(metaclass=Singleton):
|
||||
before_download: Callable[[], Any] = None,
|
||||
after_download: Callable[[T], Any] = None,
|
||||
on_cancel: Callable[[], Any] = None,
|
||||
) -> 'CacheManager.Result[T]':
|
||||
result: 'CacheManager.Result[T]' = CacheManager.Result()
|
||||
) -> "CacheManager.Result[T]":
|
||||
result: "CacheManager.Result[T]" = CacheManager.Result()
|
||||
|
||||
def future_fn() -> T:
|
||||
if before_download:
|
||||
@@ -229,7 +229,8 @@ class CacheManager(metaclass=Singleton):
|
||||
|
||||
if after_download is not None:
|
||||
result.future.add_done_callback(
|
||||
lambda f: after_download and after_download(f.result()))
|
||||
lambda f: after_download and after_download(f.result())
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -240,8 +241,8 @@ class CacheManager(metaclass=Singleton):
|
||||
return self.future.result()
|
||||
|
||||
raise Exception(
|
||||
'CacheManager.Result did not have either a data or future '
|
||||
'member.')
|
||||
"CacheManager.Result did not have either a data or future " "member."
|
||||
)
|
||||
|
||||
def add_done_callback(self, fn: Callable, *args):
|
||||
if self.future is not None:
|
||||
@@ -268,11 +269,11 @@ class CacheManager(metaclass=Singleton):
|
||||
|
||||
@staticmethod
|
||||
def shutdown():
|
||||
logging.info('CacheManager shutdown start')
|
||||
logging.info("CacheManager shutdown start")
|
||||
CacheManager.should_exit = True
|
||||
CacheManager.executor.shutdown()
|
||||
CacheManager._instance.save_cache_info()
|
||||
logging.info('CacheManager shutdown complete')
|
||||
logging.info("CacheManager shutdown complete")
|
||||
|
||||
class CacheEncoder(json.JSONEncoder):
|
||||
def default(self, obj: Any) -> Optional[Union[int, List, Dict]]:
|
||||
@@ -331,7 +332,8 @@ class CacheManager(metaclass=Singleton):
|
||||
disable_cert_verify=self.app_config.server.disable_cert_verify,
|
||||
)
|
||||
self.download_limiter_semaphore = threading.Semaphore(
|
||||
self.app_config.concurrent_download_limit)
|
||||
self.app_config.concurrent_download_limit
|
||||
)
|
||||
|
||||
self.load_cache_info()
|
||||
|
||||
@@ -346,7 +348,7 @@ class CacheManager(metaclass=Singleton):
|
||||
if not self.nmclient_initialized:
|
||||
# Only look at the active WiFi connections.
|
||||
for ac in self.networkmanager_client.get_active_connections():
|
||||
if ac.get_connection_type() != '802-11-wireless':
|
||||
if ac.get_connection_type() != "802-11-wireless":
|
||||
continue
|
||||
devs = ac.get_devices()
|
||||
if len(devs) != 1:
|
||||
@@ -359,71 +361,65 @@ class CacheManager(metaclass=Singleton):
|
||||
return self._current_ssids
|
||||
|
||||
def load_cache_info(self):
|
||||
cache_meta_file = self.calculate_abs_path('.cache_meta')
|
||||
cache_meta_file = self.calculate_abs_path(".cache_meta")
|
||||
|
||||
meta_json = {}
|
||||
if cache_meta_file.exists():
|
||||
with open(cache_meta_file, 'r') as f:
|
||||
with open(cache_meta_file, "r") as f:
|
||||
try:
|
||||
meta_json = json.load(f)
|
||||
except json.decoder.JSONDecodeError:
|
||||
# Just continue with the default meta_json.
|
||||
logging.warning(
|
||||
'Unable to load cache', stack_info=True)
|
||||
logging.warning("Unable to load cache", stack_info=True)
|
||||
|
||||
cache_version = meta_json.get('version', 0)
|
||||
cache_version = meta_json.get("version", 0)
|
||||
|
||||
if cache_version < 1:
|
||||
logging.info('Migrating cache to version 1.')
|
||||
cover_art_re = re.compile(r'(\d+)_(\d+)')
|
||||
abs_path = self.calculate_abs_path('cover_art/')
|
||||
logging.info("Migrating cache to version 1.")
|
||||
cover_art_re = re.compile(r"(\d+)_(\d+)")
|
||||
abs_path = self.calculate_abs_path("cover_art/")
|
||||
abs_path.mkdir(parents=True, exist_ok=True)
|
||||
for cover_art_file in abs_path.iterdir():
|
||||
match = cover_art_re.match(cover_art_file.name)
|
||||
if match:
|
||||
art_id, dimensions = map(int, match.groups())
|
||||
if dimensions == 1000:
|
||||
no_dimens = cover_art_file.parent.joinpath(
|
||||
'{art_id}')
|
||||
logging.debug(
|
||||
f'Moving {cover_art_file} to {no_dimens}')
|
||||
no_dimens = cover_art_file.parent.joinpath("{art_id}")
|
||||
logging.debug(f"Moving {cover_art_file} to {no_dimens}")
|
||||
shutil.move(cover_art_file, no_dimens)
|
||||
else:
|
||||
logging.debug(f'Deleting {cover_art_file}')
|
||||
logging.debug(f"Deleting {cover_art_file}")
|
||||
cover_art_file.unlink()
|
||||
|
||||
self.cache['version'] = 1
|
||||
self.cache["version"] = 1
|
||||
|
||||
cache_configs = [
|
||||
# Playlists
|
||||
('playlists', Playlist, list),
|
||||
('playlist_details', PlaylistWithSongs, dict),
|
||||
('genres', Genre, list),
|
||||
('song_details', Child, dict),
|
||||
|
||||
("playlists", Playlist, list),
|
||||
("playlist_details", PlaylistWithSongs, dict),
|
||||
("genres", Genre, list),
|
||||
("song_details", Child, dict),
|
||||
# Non-ID3 caches
|
||||
('music_directories', Directory, dict),
|
||||
('indexes', Artist, list),
|
||||
|
||||
("music_directories", Directory, dict),
|
||||
("indexes", Artist, list),
|
||||
# ID3 caches
|
||||
('albums', AlbumWithSongsID3, 'dict-list'),
|
||||
('album_details', AlbumWithSongsID3, dict),
|
||||
('artists', ArtistID3, list),
|
||||
('artist_details', ArtistWithAlbumsID3, dict),
|
||||
('artist_infos', ArtistInfo2, dict),
|
||||
("albums", AlbumWithSongsID3, "dict-list"),
|
||||
("album_details", AlbumWithSongsID3, dict),
|
||||
("artists", ArtistID3, list),
|
||||
("artist_details", ArtistWithAlbumsID3, dict),
|
||||
("artist_infos", ArtistInfo2, dict),
|
||||
]
|
||||
for name, type_name, default in cache_configs:
|
||||
if default == list:
|
||||
self.cache[name] = [
|
||||
type_name.from_json(x)
|
||||
for x in meta_json.get(name) or []
|
||||
type_name.from_json(x) for x in meta_json.get(name) or []
|
||||
]
|
||||
elif default == dict:
|
||||
self.cache[name] = {
|
||||
id: type_name.from_json(x)
|
||||
for id, x in (meta_json.get(name) or {}).items()
|
||||
}
|
||||
elif default == 'dict-list':
|
||||
elif default == "dict-list":
|
||||
self.cache[name] = {
|
||||
n: [type_name.from_json(x) for x in xs]
|
||||
for n, xs in (meta_json.get(name) or {}).items()
|
||||
@@ -432,46 +428,43 @@ class CacheManager(metaclass=Singleton):
|
||||
def save_cache_info(self):
|
||||
os.makedirs(self.app_config.cache_location, exist_ok=True)
|
||||
|
||||
cache_meta_file = self.calculate_abs_path('.cache_meta')
|
||||
cache_meta_file = self.calculate_abs_path(".cache_meta")
|
||||
os.makedirs(os.path.dirname(cache_meta_file), exist_ok=True)
|
||||
with open(cache_meta_file, 'w+') as f, self.cache_lock:
|
||||
f.write(
|
||||
json.dumps(
|
||||
self.cache, indent=2, cls=CacheManager.CacheEncoder))
|
||||
with open(cache_meta_file, "w+") as f, self.cache_lock:
|
||||
f.write(json.dumps(self.cache, indent=2, cls=CacheManager.CacheEncoder))
|
||||
|
||||
def save_file(self, absolute_path: Path, data: bytes):
|
||||
# Make the necessary directories and write to file.
|
||||
os.makedirs(absolute_path.parent, exist_ok=True)
|
||||
with open(absolute_path, 'wb+') as f:
|
||||
with open(absolute_path, "wb+") as f:
|
||||
f.write(data)
|
||||
|
||||
def calculate_abs_path(self, *relative_paths) -> Path:
|
||||
assert self.app_config.server is not None
|
||||
return Path(self.app_config.cache_location).joinpath(
|
||||
self.app_config.server.strhash(), *relative_paths)
|
||||
self.app_config.server.strhash(), *relative_paths
|
||||
)
|
||||
|
||||
def calculate_download_path(self, *relative_paths) -> Path:
|
||||
"""
|
||||
Determine where to temporarily put the file as it is downloading.
|
||||
"""
|
||||
assert self.app_config.server is not None
|
||||
xdg_cache_home = (
|
||||
os.environ.get('XDG_CACHE_HOME')
|
||||
or os.path.expanduser('~/.cache'))
|
||||
xdg_cache_home = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser(
|
||||
"~/.cache"
|
||||
)
|
||||
return Path(xdg_cache_home).joinpath(
|
||||
'sublime-music',
|
||||
self.app_config.server.strhash(),
|
||||
*relative_paths,
|
||||
"sublime-music", self.app_config.server.strhash(), *relative_paths,
|
||||
)
|
||||
|
||||
def return_cached_or_download(
|
||||
self,
|
||||
relative_path: Union[Path, str],
|
||||
download_fn: Callable[[], bytes],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
allow_download: bool = True,
|
||||
) -> 'CacheManager.Result[str]':
|
||||
self,
|
||||
relative_path: Union[Path, str],
|
||||
download_fn: Callable[[], bytes],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
allow_download: bool = True,
|
||||
) -> "CacheManager.Result[str]":
|
||||
abs_path = self.calculate_abs_path(relative_path)
|
||||
abs_path_str = str(abs_path)
|
||||
download_path = self.calculate_download_path(relative_path)
|
||||
@@ -480,7 +473,7 @@ class CacheManager(metaclass=Singleton):
|
||||
return CacheManager.Result.from_data(abs_path_str)
|
||||
|
||||
if not allow_download:
|
||||
return CacheManager.Result.from_data('')
|
||||
return CacheManager.Result.from_data("")
|
||||
|
||||
def do_download() -> str:
|
||||
resource_downloading = False
|
||||
@@ -491,14 +484,14 @@ class CacheManager(metaclass=Singleton):
|
||||
self.current_downloads.add(abs_path_str)
|
||||
|
||||
if resource_downloading:
|
||||
logging.info(f'{abs_path} already being downloaded.')
|
||||
logging.info(f"{abs_path} already being downloaded.")
|
||||
# The resource is already being downloaded. Busy loop until
|
||||
# it has completed. Then, just return the path to the
|
||||
# resource.
|
||||
while abs_path_str in self.current_downloads:
|
||||
sleep(0.2)
|
||||
else:
|
||||
logging.info(f'{abs_path} not found. Downloading...')
|
||||
logging.info(f"{abs_path} not found. Downloading...")
|
||||
|
||||
os.makedirs(download_path.parent, exist_ok=True)
|
||||
try:
|
||||
@@ -512,7 +505,7 @@ class CacheManager(metaclass=Singleton):
|
||||
if download_path.exists():
|
||||
shutil.move(str(download_path), abs_path)
|
||||
|
||||
logging.info(f'{abs_path} downloaded. Returning.')
|
||||
logging.info(f"{abs_path} downloaded. Returning.")
|
||||
return abs_path_str
|
||||
|
||||
def after_download(path: str):
|
||||
@@ -531,7 +524,7 @@ class CacheManager(metaclass=Singleton):
|
||||
return CacheManager.executor.submit(fn, *args)
|
||||
|
||||
def delete_cached_cover_art(self, id: int):
|
||||
relative_path = f'cover_art/*{id}*'
|
||||
relative_path = f"cover_art/*{id}*"
|
||||
|
||||
abs_path = self.calculate_abs_path(relative_path)
|
||||
|
||||
@@ -542,14 +535,13 @@ class CacheManager(metaclass=Singleton):
|
||||
self,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[List[Playlist]]':
|
||||
if self.cache.get('playlists') and not force:
|
||||
return CacheManager.Result.from_data(
|
||||
self.cache['playlists'] or [])
|
||||
) -> "CacheManager.Result[List[Playlist]]":
|
||||
if self.cache.get("playlists") and not force:
|
||||
return CacheManager.Result.from_data(self.cache["playlists"] or [])
|
||||
|
||||
def after_download(playlists: List[Playlist]):
|
||||
with self.cache_lock:
|
||||
self.cache['playlists'] = playlists
|
||||
self.cache["playlists"] = playlists
|
||||
self.save_cache_info()
|
||||
|
||||
return CacheManager.Result.from_server(
|
||||
@@ -559,11 +551,11 @@ class CacheManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def invalidate_playlists_cache(self):
|
||||
if not self.cache.get('playlists'):
|
||||
if not self.cache.get("playlists"):
|
||||
return
|
||||
|
||||
with self.cache_lock:
|
||||
self.cache['playlists'] = []
|
||||
self.cache["playlists"] = []
|
||||
self.save_cache_info()
|
||||
|
||||
def get_playlist(
|
||||
@@ -571,19 +563,18 @@ class CacheManager(metaclass=Singleton):
|
||||
playlist_id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[PlaylistWithSongs]':
|
||||
playlist_details = self.cache.get('playlist_details', {})
|
||||
) -> "CacheManager.Result[PlaylistWithSongs]":
|
||||
playlist_details = self.cache.get("playlist_details", {})
|
||||
if playlist_id in playlist_details and not force:
|
||||
return CacheManager.Result.from_data(
|
||||
playlist_details[playlist_id])
|
||||
return CacheManager.Result.from_data(playlist_details[playlist_id])
|
||||
|
||||
def after_download(playlist: PlaylistWithSongs):
|
||||
with self.cache_lock:
|
||||
self.cache['playlist_details'][playlist_id] = playlist
|
||||
self.cache["playlist_details"][playlist_id] = playlist
|
||||
|
||||
# Playlists have the song details, so save those too.
|
||||
for song in (playlist.entry or []):
|
||||
self.cache['song_details'][song.id] = song
|
||||
for song in playlist.entry or []:
|
||||
self.cache["song_details"][song.id] = song
|
||||
|
||||
self.save_cache_info()
|
||||
|
||||
@@ -608,7 +599,7 @@ class CacheManager(metaclass=Singleton):
|
||||
def do_update_playlist():
|
||||
self.server.update_playlist(playlist_id, *args, **kwargs)
|
||||
with self.cache_lock:
|
||||
del self.cache['playlist_details'][playlist_id]
|
||||
del self.cache["playlist_details"][playlist_id]
|
||||
|
||||
return CacheManager.create_future(do_update_playlist)
|
||||
|
||||
@@ -616,8 +607,8 @@ class CacheManager(metaclass=Singleton):
|
||||
self,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[List[ArtistID3]]':
|
||||
cache_name = 'artists'
|
||||
) -> "CacheManager.Result[List[ArtistID3]]":
|
||||
cache_name = "artists"
|
||||
|
||||
if self.cache.get(cache_name) and not force:
|
||||
return CacheManager.Result.from_data(self.cache[cache_name])
|
||||
@@ -644,12 +635,11 @@ class CacheManager(metaclass=Singleton):
|
||||
artist_id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[ArtistWithAlbumsID3]':
|
||||
cache_name = 'artist_details'
|
||||
) -> "CacheManager.Result[ArtistWithAlbumsID3]":
|
||||
cache_name = "artist_details"
|
||||
|
||||
if artist_id in self.cache.get(cache_name, {}) and not force:
|
||||
return CacheManager.Result.from_data(
|
||||
self.cache[cache_name][artist_id])
|
||||
return CacheManager.Result.from_data(self.cache[cache_name][artist_id])
|
||||
|
||||
def after_download(artist: ArtistWithAlbumsID3):
|
||||
with self.cache_lock:
|
||||
@@ -663,11 +653,11 @@ class CacheManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def get_indexes(
|
||||
self,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[List[Artist]]':
|
||||
cache_name = 'indexes'
|
||||
self,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> "CacheManager.Result[List[Artist]]":
|
||||
cache_name = "indexes"
|
||||
|
||||
if self.cache.get(cache_name) and not force:
|
||||
return CacheManager.Result.from_data(self.cache[cache_name])
|
||||
@@ -690,16 +680,15 @@ class CacheManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def get_music_directory(
|
||||
self,
|
||||
id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[Directory]':
|
||||
cache_name = 'music_directories'
|
||||
self,
|
||||
id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> "CacheManager.Result[Directory]":
|
||||
cache_name = "music_directories"
|
||||
|
||||
if id in self.cache.get(cache_name, {}) and not force:
|
||||
return CacheManager.Result.from_data(
|
||||
self.cache[cache_name][id])
|
||||
return CacheManager.Result.from_data(self.cache[cache_name][id])
|
||||
|
||||
def after_download(directory: Directory):
|
||||
with self.cache_lock:
|
||||
@@ -713,16 +702,15 @@ class CacheManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def get_artist_info(
|
||||
self,
|
||||
artist_id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[ArtistInfo2]':
|
||||
cache_name = 'artist_infos'
|
||||
self,
|
||||
artist_id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> "CacheManager.Result[ArtistInfo2]":
|
||||
cache_name = "artist_infos"
|
||||
|
||||
if artist_id in self.cache.get(cache_name, {}) and not force:
|
||||
return CacheManager.Result.from_data(
|
||||
self.cache[cache_name][artist_id])
|
||||
return CacheManager.Result.from_data(self.cache[cache_name][artist_id])
|
||||
|
||||
def after_download(artist_info: ArtistInfo2):
|
||||
if not artist_info:
|
||||
@@ -733,59 +721,63 @@ class CacheManager(metaclass=Singleton):
|
||||
self.save_cache_info()
|
||||
|
||||
return CacheManager.Result.from_server(
|
||||
lambda:
|
||||
(self.server.get_artist_info2(id=artist_id) or ArtistInfo2()),
|
||||
lambda: (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()),
|
||||
before_download=before_download,
|
||||
after_download=after_download,
|
||||
)
|
||||
|
||||
def get_artist_artwork(
|
||||
self,
|
||||
artist: Union[Artist, ArtistID3],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[str]':
|
||||
self,
|
||||
artist: Union[Artist, ArtistID3],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> "CacheManager.Result[str]":
|
||||
def do_get_artist_artwork(
|
||||
artist_info: ArtistInfo2) -> 'CacheManager.Result[str]':
|
||||
lastfm_url = ''.join(artist_info.largeImageUrl or [])
|
||||
artist_info: ArtistInfo2,
|
||||
) -> "CacheManager.Result[str]":
|
||||
lastfm_url = "".join(artist_info.largeImageUrl or [])
|
||||
|
||||
is_placeholder = lastfm_url == ''
|
||||
is_placeholder = lastfm_url == ""
|
||||
is_placeholder |= lastfm_url.endswith(
|
||||
'2a96cbd8b46e442fc41c2b86b821562f.png')
|
||||
"2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
)
|
||||
is_placeholder |= lastfm_url.endswith(
|
||||
'1024px-No_image_available.svg.png')
|
||||
"1024px-No_image_available.svg.png"
|
||||
)
|
||||
|
||||
# If it is the placeholder LastFM image, try and use the cover
|
||||
# art filename given by the server.
|
||||
if is_placeholder:
|
||||
if isinstance(artist, (ArtistWithAlbumsID3, ArtistID3)):
|
||||
if artist.coverArt:
|
||||
return CacheManager.get_cover_art_filename(artist.coverArt)
|
||||
elif (
|
||||
isinstance(artist, ArtistWithAlbumsID3)
|
||||
and artist.album
|
||||
and len(artist.album) > 0
|
||||
):
|
||||
return CacheManager.get_cover_art_filename(
|
||||
artist.coverArt)
|
||||
elif (isinstance(artist, ArtistWithAlbumsID3)
|
||||
and artist.album and len(artist.album) > 0):
|
||||
return CacheManager.get_cover_art_filename(
|
||||
artist.album[0].coverArt)
|
||||
artist.album[0].coverArt
|
||||
)
|
||||
|
||||
elif (isinstance(artist, Directory)
|
||||
and len(artist.child) > 0):
|
||||
elif isinstance(artist, Directory) and len(artist.child) > 0:
|
||||
# Retrieve the first album's cover art
|
||||
return CacheManager.get_cover_art_filename(
|
||||
artist.child[0].coverArt)
|
||||
artist.child[0].coverArt
|
||||
)
|
||||
|
||||
if lastfm_url == '':
|
||||
return CacheManager.Result.from_data('')
|
||||
if lastfm_url == "":
|
||||
return CacheManager.Result.from_data("")
|
||||
|
||||
url_hash = hashlib.md5(lastfm_url.encode('utf-8')).hexdigest()
|
||||
url_hash = hashlib.md5(lastfm_url.encode("utf-8")).hexdigest()
|
||||
return self.return_cached_or_download(
|
||||
f'cover_art/artist.{url_hash}',
|
||||
f"cover_art/artist.{url_hash}",
|
||||
lambda: requests.get(lastfm_url).content,
|
||||
before_download=before_download,
|
||||
force=force,
|
||||
)
|
||||
|
||||
def download_fn(
|
||||
artist_info: CacheManager.Result[ArtistInfo2]) -> str:
|
||||
def download_fn(artist_info: CacheManager.Result[ArtistInfo2]) -> str:
|
||||
# In this case, artist_info is a future, so we have to wait for
|
||||
# its result before calculating. Then, immediately unwrap the
|
||||
# result() because we are already within a future.
|
||||
@@ -794,8 +786,7 @@ class CacheManager(metaclass=Singleton):
|
||||
artist_info = CacheManager.get_artist_info(artist.id)
|
||||
if artist_info.is_future:
|
||||
return CacheManager.Result.from_server(
|
||||
lambda: download_fn(artist_info),
|
||||
before_download=before_download,
|
||||
lambda: download_fn(artist_info), before_download=before_download,
|
||||
)
|
||||
else:
|
||||
return do_get_artist_artwork(artist_info.result())
|
||||
@@ -807,27 +798,22 @@ class CacheManager(metaclass=Singleton):
|
||||
force: bool = False,
|
||||
# Look at documentation for get_album_list in server.py:
|
||||
**params,
|
||||
) -> 'CacheManager.Result[List[AlbumID3]]':
|
||||
cache_name = 'albums'
|
||||
) -> "CacheManager.Result[List[AlbumID3]]":
|
||||
cache_name = "albums"
|
||||
|
||||
if (len(self.cache.get(cache_name, {}).get(type_, [])) > 0
|
||||
and not force):
|
||||
return CacheManager.Result.from_data(
|
||||
self.cache[cache_name][type_])
|
||||
if len(self.cache.get(cache_name, {}).get(type_, [])) > 0 and not force:
|
||||
return CacheManager.Result.from_data(self.cache[cache_name][type_])
|
||||
|
||||
def do_get_album_list() -> List[AlbumID3]:
|
||||
def get_page(
|
||||
offset: int,
|
||||
page_size: int = 500,
|
||||
) -> List[AlbumID3]:
|
||||
return self.server.get_album_list2(
|
||||
type_,
|
||||
size=page_size,
|
||||
offset=offset,
|
||||
**params,
|
||||
).album or []
|
||||
def get_page(offset: int, page_size: int = 500,) -> List[AlbumID3]:
|
||||
return (
|
||||
self.server.get_album_list2(
|
||||
type_, size=page_size, offset=offset, **params,
|
||||
).album
|
||||
or []
|
||||
)
|
||||
|
||||
page_size = 40 if type_ == 'random' else 500
|
||||
page_size = 40 if type_ == "random" else 500
|
||||
offset = 0
|
||||
|
||||
next_page = get_page(offset, page_size=page_size)
|
||||
@@ -859,20 +845,19 @@ class CacheManager(metaclass=Singleton):
|
||||
album_id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[AlbumWithSongsID3]':
|
||||
cache_name = 'album_details'
|
||||
) -> "CacheManager.Result[AlbumWithSongsID3]":
|
||||
cache_name = "album_details"
|
||||
|
||||
if album_id in self.cache.get(cache_name, {}) and not force:
|
||||
return CacheManager.Result.from_data(
|
||||
self.cache[cache_name][album_id])
|
||||
return CacheManager.Result.from_data(self.cache[cache_name][album_id])
|
||||
|
||||
def after_download(album: AlbumWithSongsID3):
|
||||
with self.cache_lock:
|
||||
self.cache[cache_name][album_id] = album
|
||||
|
||||
# Albums have the song details as well, so save those too.
|
||||
for song in album.get('song', []):
|
||||
self.cache['song_details'][song.id] = song
|
||||
for song in album.get("song", []):
|
||||
self.cache["song_details"][song.id] = song
|
||||
self.save_cache_info()
|
||||
|
||||
return CacheManager.Result.from_server(
|
||||
@@ -882,9 +867,7 @@ class CacheManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def batch_delete_cached_songs(
|
||||
self,
|
||||
song_ids: List[int],
|
||||
on_song_delete: Callable[[], None],
|
||||
self, song_ids: List[int], on_song_delete: Callable[[], None],
|
||||
) -> Future:
|
||||
def do_delete_cached_songs():
|
||||
# Do the actual download.
|
||||
@@ -899,10 +882,10 @@ class CacheManager(metaclass=Singleton):
|
||||
return CacheManager.create_future(do_delete_cached_songs)
|
||||
|
||||
def batch_download_songs(
|
||||
self,
|
||||
song_ids: List[int],
|
||||
before_download: Callable[[], None],
|
||||
on_song_download_complete: Callable[[], None],
|
||||
self,
|
||||
song_ids: List[int],
|
||||
before_download: Callable[[], None],
|
||||
on_song_download_complete: Callable[[], None],
|
||||
) -> Future:
|
||||
def do_download_song(song_id: int):
|
||||
try:
|
||||
@@ -941,18 +924,19 @@ class CacheManager(metaclass=Singleton):
|
||||
return CacheManager.create_future(do_batch_download_songs)
|
||||
|
||||
def get_cover_art_filename(
|
||||
self,
|
||||
id: str,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
allow_download: bool = True,
|
||||
) -> 'CacheManager.Result[str]':
|
||||
self,
|
||||
id: str,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
allow_download: bool = True,
|
||||
) -> "CacheManager.Result[str]":
|
||||
if id is None:
|
||||
default_art_path = 'ui/images/default-album-art.png'
|
||||
default_art_path = "ui/images/default-album-art.png"
|
||||
return CacheManager.Result.from_data(
|
||||
str(Path(__file__).parent.joinpath(default_art_path)))
|
||||
str(Path(__file__).parent.joinpath(default_art_path))
|
||||
)
|
||||
return self.return_cached_or_download(
|
||||
f'cover_art/{id}',
|
||||
f"cover_art/{id}",
|
||||
lambda: self.server.get_cover_art(id),
|
||||
before_download=before_download,
|
||||
force=force,
|
||||
@@ -960,15 +944,14 @@ class CacheManager(metaclass=Singleton):
|
||||
)
|
||||
|
||||
def get_song_details(
|
||||
self,
|
||||
song_id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[Child]':
|
||||
cache_name = 'song_details'
|
||||
self,
|
||||
song_id: int,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> "CacheManager.Result[Child]":
|
||||
cache_name = "song_details"
|
||||
if self.cache[cache_name].get(song_id) and not force:
|
||||
return CacheManager.Result.from_data(
|
||||
self.cache[cache_name][song_id])
|
||||
return CacheManager.Result.from_data(self.cache[cache_name][song_id])
|
||||
|
||||
def after_download(song_details: Child):
|
||||
with self.cache_lock:
|
||||
@@ -985,13 +968,11 @@ class CacheManager(metaclass=Singleton):
|
||||
return CacheManager.create_future(self.server.get_play_queue)
|
||||
|
||||
def save_play_queue(
|
||||
self,
|
||||
play_queue: List[str],
|
||||
current: str,
|
||||
position: float,
|
||||
self, play_queue: List[str], current: str, position: float,
|
||||
):
|
||||
CacheManager.create_future(
|
||||
self.server.save_play_queue, play_queue, current, position)
|
||||
self.server.save_play_queue, play_queue, current, position
|
||||
)
|
||||
|
||||
def scrobble(self, song_id: int) -> Future:
|
||||
def do_scrobble():
|
||||
@@ -1000,10 +981,7 @@ class CacheManager(metaclass=Singleton):
|
||||
return CacheManager.create_future(do_scrobble)
|
||||
|
||||
def get_song_filename_or_stream(
|
||||
self,
|
||||
song: Child,
|
||||
format: str = None,
|
||||
force_stream: bool = False,
|
||||
self, song: Child, format: str = None, force_stream: bool = False,
|
||||
) -> Tuple[str, bool]:
|
||||
abs_path = self.calculate_abs_path(song.path)
|
||||
if abs_path.exists() and not force_stream:
|
||||
@@ -1011,16 +989,16 @@ class CacheManager(metaclass=Singleton):
|
||||
return (self.server.get_stream_url(song.id, format=format), True)
|
||||
|
||||
def get_genres(
|
||||
self,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> 'CacheManager.Result[List[Genre]]':
|
||||
if self.cache.get('genres') and not force:
|
||||
return CacheManager.Result.from_data(self.cache['genres'])
|
||||
self,
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
force: bool = False,
|
||||
) -> "CacheManager.Result[List[Genre]]":
|
||||
if self.cache.get("genres") and not force:
|
||||
return CacheManager.Result.from_data(self.cache["genres"])
|
||||
|
||||
def after_download(genres: List[Genre]):
|
||||
with self.cache_lock:
|
||||
self.cache['genres'] = genres
|
||||
self.cache["genres"] = genres
|
||||
self.save_cache_info()
|
||||
|
||||
return CacheManager.Result.from_server(
|
||||
@@ -1034,9 +1012,9 @@ class CacheManager(metaclass=Singleton):
|
||||
query: str,
|
||||
search_callback: Callable[[SearchResult, bool], None],
|
||||
before_download: Callable[[], None] = lambda: None,
|
||||
) -> 'CacheManager.Result':
|
||||
if query == '':
|
||||
search_callback(SearchResult(''), True)
|
||||
) -> "CacheManager.Result":
|
||||
if query == "":
|
||||
search_callback(SearchResult(""), True)
|
||||
return CacheManager.Result.from_data(None)
|
||||
|
||||
before_download()
|
||||
@@ -1058,11 +1036,11 @@ class CacheManager(metaclass=Singleton):
|
||||
# Local Results
|
||||
search_result = SearchResult(query)
|
||||
search_result.add_results(
|
||||
'album', itertools.chain(*self.cache['albums'].values()))
|
||||
search_result.add_results('artist', self.cache['artists'])
|
||||
search_result.add_results(
|
||||
'song', self.cache['song_details'].values())
|
||||
search_result.add_results('playlist', self.cache['playlists'])
|
||||
"album", itertools.chain(*self.cache["albums"].values())
|
||||
)
|
||||
search_result.add_results("artist", self.cache["artists"])
|
||||
search_result.add_results("song", self.cache["song_details"].values())
|
||||
search_result.add_results("playlist", self.cache["playlists"])
|
||||
search_callback(search_result, False)
|
||||
|
||||
# Wait longer to see if the user types anything else so we
|
||||
@@ -1078,9 +1056,9 @@ class CacheManager(metaclass=Singleton):
|
||||
# SearchResult. If it fails, that's fine, we will use the
|
||||
# finally to always return a final SearchResult to the UI.
|
||||
server_result = search_fn(query)
|
||||
search_result.add_results('album', server_result.album)
|
||||
search_result.add_results('artist', server_result.artist)
|
||||
search_result.add_results('song', server_result.song)
|
||||
search_result.add_results("album", server_result.album)
|
||||
search_result.add_results("artist", server_result.artist)
|
||||
search_result.add_results("song", server_result.song)
|
||||
except Exception:
|
||||
# We really don't care about what the exception was (could
|
||||
# be connection error, could be invalid JSON, etc.) because
|
||||
@@ -1095,8 +1073,7 @@ class CacheManager(metaclass=Singleton):
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
return CacheManager.Result.from_server(
|
||||
do_search, on_cancel=on_cancel)
|
||||
return CacheManager.Result.from_server(do_search, on_cancel=on_cancel)
|
||||
|
||||
def get_cached_status(self, song: Child) -> SongCacheStatus:
|
||||
cache_path = self.calculate_abs_path(song.path)
|
||||
@@ -1113,10 +1090,9 @@ class CacheManager(metaclass=Singleton):
|
||||
_instance: Optional[__CacheManagerInternal] = None
|
||||
|
||||
def __init__(self):
|
||||
raise Exception('Do not instantiate the CacheManager.')
|
||||
raise Exception("Do not instantiate the CacheManager.")
|
||||
|
||||
@staticmethod
|
||||
def reset(app_config: AppConfiguration):
|
||||
CacheManager._instance = CacheManager.__CacheManagerInternal(
|
||||
app_config)
|
||||
CacheManager._instance = CacheManager.__CacheManagerInternal(app_config)
|
||||
similarity_ratio.cache_clear()
|
||||
|
@@ -26,26 +26,26 @@ class ReplayGainType(Enum):
|
||||
ALBUM = 2
|
||||
|
||||
def as_string(self) -> str:
|
||||
return ['no', 'track', 'album'][self.value]
|
||||
return ["no", "track", "album"][self.value]
|
||||
|
||||
@staticmethod
|
||||
def from_string(replay_gain_type: str) -> 'ReplayGainType':
|
||||
def from_string(replay_gain_type: str) -> "ReplayGainType":
|
||||
return {
|
||||
'no': ReplayGainType.NO,
|
||||
'disabled': ReplayGainType.NO,
|
||||
'track': ReplayGainType.TRACK,
|
||||
'album': ReplayGainType.ALBUM,
|
||||
"no": ReplayGainType.NO,
|
||||
"disabled": ReplayGainType.NO,
|
||||
"track": ReplayGainType.TRACK,
|
||||
"album": ReplayGainType.ALBUM,
|
||||
}[replay_gain_type.lower()]
|
||||
|
||||
|
||||
@dataclass(unsafe_hash=True)
|
||||
class ServerConfiguration:
|
||||
name: str = 'Default'
|
||||
server_address: str = 'http://yourhost'
|
||||
local_network_address: str = ''
|
||||
local_network_ssid: str = ''
|
||||
username: str = ''
|
||||
password: str = ''
|
||||
name: str = "Default"
|
||||
server_address: str = "http://yourhost"
|
||||
local_network_address: str = ""
|
||||
local_network_ssid: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
sync_enabled: bool = True
|
||||
disable_cert_verify: bool = False
|
||||
version: int = 0
|
||||
@@ -70,14 +70,14 @@ class ServerConfiguration:
|
||||
'6df23dc03f9b54cc38a0fc1483df6e21'
|
||||
"""
|
||||
server_info = self.name + self.server_address + self.username
|
||||
return hashlib.md5(server_info.encode('utf-8')).hexdigest()
|
||||
return hashlib.md5(server_info.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfiguration:
|
||||
servers: List[ServerConfiguration] = field(default_factory=list)
|
||||
current_server_index: int = -1
|
||||
cache_location: str = ''
|
||||
cache_location: str = ""
|
||||
max_cache_size_mb: int = -1 # -1 means unlimited
|
||||
always_stream: bool = False # always stream instead of downloading songs
|
||||
download_on_stream: bool = True # also download when streaming a song
|
||||
@@ -91,10 +91,10 @@ class AppConfiguration:
|
||||
filename: Optional[Path] = None
|
||||
|
||||
@staticmethod
|
||||
def load_from_file(filename: Path) -> 'AppConfiguration':
|
||||
def load_from_file(filename: Path) -> "AppConfiguration":
|
||||
args = {}
|
||||
if filename.exists():
|
||||
with open(filename, 'r') as f:
|
||||
with open(filename, "r") as f:
|
||||
field_names = {f.name for f in fields(AppConfiguration)}
|
||||
args = yaml.load(f, Loader=yaml.CLoader).items()
|
||||
args = dict(filter(lambda kv: kv[0] in field_names, args))
|
||||
@@ -107,13 +107,12 @@ class AppConfiguration:
|
||||
def __post_init__(self):
|
||||
# Default the cache_location to ~/.local/share/sublime-music
|
||||
if not self.cache_location:
|
||||
path = Path(os.environ.get('XDG_DATA_HOME') or '~/.local/share')
|
||||
path = path.expanduser().joinpath('sublime-music').resolve()
|
||||
path = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
|
||||
path = path.expanduser().joinpath("sublime-music").resolve()
|
||||
self.cache_location = path.as_posix()
|
||||
|
||||
# Deserialize the YAML into the ServerConfiguration object.
|
||||
if (len(self.servers) > 0
|
||||
and type(self.servers[0]) != ServerConfiguration):
|
||||
if len(self.servers) > 0 and type(self.servers[0]) != ServerConfiguration:
|
||||
self.servers = [ServerConfiguration(**sc) for sc in self.servers]
|
||||
|
||||
self._state = None
|
||||
@@ -152,17 +151,17 @@ class AppConfiguration:
|
||||
self._current_server_hash = self.server.strhash()
|
||||
if self.state_file_location.exists():
|
||||
try:
|
||||
with open(self.state_file_location, 'rb') as f:
|
||||
with open(self.state_file_location, "rb") as f:
|
||||
self._state = UIState(**pickle.load(f))
|
||||
except Exception:
|
||||
logging.warning(
|
||||
f"Couldn't load state from {self.state_file_location}")
|
||||
logging.warning(f"Couldn't load state from {self.state_file_location}")
|
||||
# Just ignore any errors, it is only UI state.
|
||||
self._state = UIState()
|
||||
|
||||
# Do the import in the function to avoid circular imports.
|
||||
from sublime.cache_manager import CacheManager
|
||||
from sublime.adapters import AdapterManager
|
||||
|
||||
CacheManager.reset(self)
|
||||
AdapterManager.reset(self)
|
||||
|
||||
@@ -171,18 +170,18 @@ class AppConfiguration:
|
||||
assert self.server is not None
|
||||
server_hash = self.server.strhash()
|
||||
|
||||
state_file_location = Path(
|
||||
os.environ.get('XDG_DATA_HOME') or '~/.local/share')
|
||||
state_file_location = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
|
||||
return state_file_location.expanduser().joinpath(
|
||||
'sublime-music', server_hash, 'state.pickle')
|
||||
"sublime-music", server_hash, "state.pickle"
|
||||
)
|
||||
|
||||
def save(self):
|
||||
# Save the config as YAML.
|
||||
self.filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.filename, 'w+') as f:
|
||||
with open(self.filename, "w+") as f:
|
||||
f.write(yaml.dump(asdict(self)))
|
||||
|
||||
# Save the state for the current server.
|
||||
self.state_file_location.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.state_file_location, 'wb+') as f:
|
||||
with open(self.state_file_location, "wb+") as f:
|
||||
pickle.dump(asdict(self.state), f)
|
||||
|
@@ -1,3 +1,3 @@
|
||||
from .manager import DBusManager, dbus_propagate
|
||||
from .manager import dbus_propagate, DBusManager
|
||||
|
||||
__all__ = ('DBusManager', 'dbus_propagate')
|
||||
__all__ = ("dbus_propagate", "DBusManager")
|
||||
|
@@ -8,6 +8,7 @@ from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple
|
||||
from deepdiff import DeepDiff
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
from sublime.adapters import AdapterManager
|
||||
from sublime.cache_manager import CacheManager
|
||||
from sublime.config import AppConfiguration
|
||||
from sublime.players import Player
|
||||
@@ -15,9 +16,8 @@ from sublime.ui.state import RepeatType
|
||||
|
||||
|
||||
def dbus_propagate(param_self: Any = None) -> Callable:
|
||||
"""
|
||||
Wraps a function which causes changes to DBus properties.
|
||||
"""
|
||||
"""Wraps a function which causes changes to DBus properties."""
|
||||
|
||||
def decorator(function: Callable) -> Callable:
|
||||
@functools.wraps(function)
|
||||
def wrapper(*args):
|
||||
@@ -38,19 +38,22 @@ class DBusManager:
|
||||
def __init__(
|
||||
self,
|
||||
connection: Gio.DBusConnection,
|
||||
do_on_method_call: Callable[[
|
||||
Gio.DBusConnection,
|
||||
str,
|
||||
str,
|
||||
str,
|
||||
str,
|
||||
GLib.Variant,
|
||||
Gio.DBusMethodInvocation,
|
||||
], None],
|
||||
do_on_method_call: Callable[
|
||||
[
|
||||
Gio.DBusConnection,
|
||||
str,
|
||||
str,
|
||||
str,
|
||||
str,
|
||||
GLib.Variant,
|
||||
Gio.DBusMethodInvocation,
|
||||
],
|
||||
None,
|
||||
],
|
||||
on_set_property: Callable[
|
||||
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None],
|
||||
get_config_and_player: Callable[[], Tuple[AppConfiguration,
|
||||
Optional[Player]]],
|
||||
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None
|
||||
],
|
||||
get_config_and_player: Callable[[], Tuple[AppConfiguration, Optional[Player]]],
|
||||
):
|
||||
self.get_config_and_player = get_config_and_player
|
||||
self.do_on_method_call = do_on_method_call
|
||||
@@ -59,21 +62,20 @@ class DBusManager:
|
||||
|
||||
def dbus_name_acquired(connection: Gio.DBusConnection, name: str):
|
||||
specs = [
|
||||
'org.mpris.MediaPlayer2.xml',
|
||||
'org.mpris.MediaPlayer2.Player.xml',
|
||||
'org.mpris.MediaPlayer2.Playlists.xml',
|
||||
'org.mpris.MediaPlayer2.TrackList.xml',
|
||||
"org.mpris.MediaPlayer2.xml",
|
||||
"org.mpris.MediaPlayer2.Player.xml",
|
||||
"org.mpris.MediaPlayer2.Playlists.xml",
|
||||
"org.mpris.MediaPlayer2.TrackList.xml",
|
||||
]
|
||||
for spec in specs:
|
||||
spec_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
f'mpris_specs/{spec}',
|
||||
os.path.dirname(__file__), f"mpris_specs/{spec}",
|
||||
)
|
||||
with open(spec_path) as f:
|
||||
node_info = Gio.DBusNodeInfo.new_for_xml(f.read())
|
||||
|
||||
connection.register_object(
|
||||
'/org/mpris/MediaPlayer2',
|
||||
"/org/mpris/MediaPlayer2",
|
||||
node_info.interfaces[0],
|
||||
self.on_method_call,
|
||||
self.on_get_property,
|
||||
@@ -86,7 +88,7 @@ class DBusManager:
|
||||
|
||||
self.bus_number = Gio.bus_own_name_on_connection(
|
||||
connection,
|
||||
'org.mpris.MediaPlayer2.sublimemusic',
|
||||
"org.mpris.MediaPlayer2.sublimemusic",
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
dbus_name_acquired,
|
||||
dbus_name_lost,
|
||||
@@ -96,12 +98,12 @@ class DBusManager:
|
||||
Gio.bus_unown_name(self.bus_number)
|
||||
|
||||
def on_get_property(
|
||||
self,
|
||||
connection: Gio.DBusConnection,
|
||||
sender: str,
|
||||
path: str,
|
||||
interface: str,
|
||||
property_name: str,
|
||||
self,
|
||||
connection: Gio.DBusConnection,
|
||||
sender: str,
|
||||
path: str,
|
||||
interface: str,
|
||||
property_name: str,
|
||||
) -> GLib.Variant:
|
||||
value = self.property_dict().get(interface, {}).get(property_name)
|
||||
return DBusManager.to_variant(value)
|
||||
@@ -120,31 +122,23 @@ class DBusManager:
|
||||
return
|
||||
|
||||
# TODO (#127): I don't even know if this works.
|
||||
if interface == 'org.freedesktop.DBus.Properties':
|
||||
if method == 'Get':
|
||||
if interface == "org.freedesktop.DBus.Properties":
|
||||
if method == "Get":
|
||||
invocation.return_value(
|
||||
self.on_get_property(
|
||||
connection, sender, path, interface, *params))
|
||||
elif method == 'Set':
|
||||
self.on_set_property(
|
||||
connection, sender, path, interface, *params)
|
||||
elif method == 'GetAll':
|
||||
self.on_get_property(connection, sender, path, interface, *params)
|
||||
)
|
||||
elif method == "Set":
|
||||
self.on_set_property(connection, sender, path, interface, *params)
|
||||
elif method == "GetAll":
|
||||
all_properties = {
|
||||
k: DBusManager.to_variant(v)
|
||||
for k, v in self.property_dict()[interface].items()
|
||||
}
|
||||
invocation.return_value(
|
||||
GLib.Variant('(a{sv})', (all_properties, )))
|
||||
invocation.return_value(GLib.Variant("(a{sv})", (all_properties,)))
|
||||
|
||||
return
|
||||
self.do_on_method_call(
|
||||
connection,
|
||||
sender,
|
||||
path,
|
||||
interface,
|
||||
method,
|
||||
params,
|
||||
invocation,
|
||||
connection, sender, path, interface, method, params, invocation,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -160,18 +154,12 @@ class DBusManager:
|
||||
|
||||
if type(value) == dict:
|
||||
return GLib.Variant(
|
||||
'a{sv}',
|
||||
{k: DBusManager.to_variant(v)
|
||||
for k, v in value.items()},
|
||||
"a{sv}", {k: DBusManager.to_variant(v) for k, v in value.items()},
|
||||
)
|
||||
|
||||
variant_type = {
|
||||
list: 'as',
|
||||
str: 's',
|
||||
int: 'i',
|
||||
float: 'd',
|
||||
bool: 'b',
|
||||
}.get(type(value))
|
||||
variant_type = {list: "as", str: "s", int: "i", float: "d", bool: "b"}.get(
|
||||
type(value)
|
||||
)
|
||||
if not variant_type:
|
||||
return value
|
||||
return GLib.Variant(variant_type, value)
|
||||
@@ -183,113 +171,97 @@ class DBusManager:
|
||||
state = config.state
|
||||
has_current_song = state.current_song is not None
|
||||
has_next_song = False
|
||||
if state.repeat_type in (RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG):
|
||||
if state.repeat_type in (RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG):
|
||||
has_next_song = True
|
||||
elif has_current_song:
|
||||
has_next_song = (
|
||||
state.current_song_index < len(state.play_queue) - 1)
|
||||
has_next_song = state.current_song_index < len(state.play_queue) - 1
|
||||
|
||||
if state.active_playlist_id is None:
|
||||
active_playlist = (False, GLib.Variant('(oss)', ('/', '', '')))
|
||||
active_playlist = (False, GLib.Variant("(oss)", ("/", "", "")))
|
||||
else:
|
||||
playlist_result = CacheManager.get_playlist(
|
||||
state.active_playlist_id)
|
||||
playlist_result = AdapterManager.get_playlist_details(
|
||||
state.active_playlist_id
|
||||
)
|
||||
|
||||
if playlist_result.is_future:
|
||||
# If we have to wait for the playlist result, just return
|
||||
# no playlist.
|
||||
active_playlist = (False, GLib.Variant('(oss)', ('/', '', '')))
|
||||
else:
|
||||
if playlist_result.data_is_available:
|
||||
playlist = playlist_result.result()
|
||||
|
||||
active_playlist = (
|
||||
True,
|
||||
GLib.Variant(
|
||||
'(oss)',
|
||||
"(oss)",
|
||||
(
|
||||
'/playlist/' + playlist.id,
|
||||
"/playlist/" + playlist.id,
|
||||
playlist.name,
|
||||
CacheManager.get_cover_art_url(playlist.coverArt),
|
||||
CacheManager.get_cover_art_url(playlist.cover_art),
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
# If we have to wait for the playlist result, just return
|
||||
# no playlist.
|
||||
active_playlist = (False, GLib.Variant("(oss)", ("/", "", "")))
|
||||
|
||||
get_playlists_result = CacheManager.get_playlists()
|
||||
if get_playlists_result.is_future:
|
||||
playlist_count = 0
|
||||
else:
|
||||
get_playlists_result = AdapterManager.get_playlists()
|
||||
if get_playlists_result.data_is_available:
|
||||
playlist_count = len(get_playlists_result.result())
|
||||
else:
|
||||
playlist_count = 0
|
||||
|
||||
return {
|
||||
'org.mpris.MediaPlayer2': {
|
||||
'CanQuit': True,
|
||||
'CanRaise': True,
|
||||
'HasTrackList': True,
|
||||
'Identity': 'Sublime Music',
|
||||
'DesktopEntry': 'sublime-music',
|
||||
'SupportedUriSchemes': [],
|
||||
'SupportedMimeTypes': [],
|
||||
"org.mpris.MediaPlayer2": {
|
||||
"CanQuit": True,
|
||||
"CanRaise": True,
|
||||
"HasTrackList": True,
|
||||
"Identity": "Sublime Music",
|
||||
"DesktopEntry": "sublime-music",
|
||||
"SupportedUriSchemes": [],
|
||||
"SupportedMimeTypes": [],
|
||||
},
|
||||
'org.mpris.MediaPlayer2.Player': {
|
||||
'PlaybackStatus': {
|
||||
(False, False): 'Stopped',
|
||||
(False, True): 'Stopped',
|
||||
(True, False): 'Paused',
|
||||
(True, True): 'Playing',
|
||||
"org.mpris.MediaPlayer2.Player": {
|
||||
"PlaybackStatus": {
|
||||
(False, False): "Stopped",
|
||||
(False, True): "Stopped",
|
||||
(True, False): "Paused",
|
||||
(True, True): "Playing",
|
||||
}[player is not None and player.song_loaded, state.playing],
|
||||
'LoopStatus':
|
||||
state.repeat_type.as_mpris_loop_status(),
|
||||
'Rate':
|
||||
1.0,
|
||||
'Shuffle':
|
||||
state.shuffle_on,
|
||||
'Metadata':
|
||||
self.get_mpris_metadata(
|
||||
state.current_song_index,
|
||||
state.play_queue,
|
||||
) if state.current_song else {},
|
||||
'Volume':
|
||||
0.0 if state.is_muted else state.volume / 100,
|
||||
'Position': (
|
||||
'x',
|
||||
"LoopStatus": state.repeat_type.as_mpris_loop_status(),
|
||||
"Rate": 1.0,
|
||||
"Shuffle": state.shuffle_on,
|
||||
"Metadata": self.get_mpris_metadata(
|
||||
state.current_song_index, state.play_queue,
|
||||
)
|
||||
if state.current_song
|
||||
else {},
|
||||
"Volume": 0.0 if state.is_muted else state.volume / 100,
|
||||
"Position": (
|
||||
"x",
|
||||
int(
|
||||
max(state.song_progress or 0, 0)
|
||||
* self.second_microsecond_conversion),
|
||||
* self.second_microsecond_conversion
|
||||
),
|
||||
),
|
||||
'MinimumRate':
|
||||
1.0,
|
||||
'MaximumRate':
|
||||
1.0,
|
||||
'CanGoNext':
|
||||
has_current_song and has_next_song,
|
||||
'CanGoPrevious':
|
||||
has_current_song,
|
||||
'CanPlay':
|
||||
True,
|
||||
'CanPause':
|
||||
True,
|
||||
'CanSeek':
|
||||
True,
|
||||
'CanControl':
|
||||
True,
|
||||
"MinimumRate": 1.0,
|
||||
"MaximumRate": 1.0,
|
||||
"CanGoNext": has_current_song and has_next_song,
|
||||
"CanGoPrevious": has_current_song,
|
||||
"CanPlay": True,
|
||||
"CanPause": True,
|
||||
"CanSeek": True,
|
||||
"CanControl": True,
|
||||
},
|
||||
'org.mpris.MediaPlayer2.TrackList': {
|
||||
'Tracks': self.get_dbus_playlist(state.play_queue),
|
||||
'CanEditTracks': False,
|
||||
"org.mpris.MediaPlayer2.TrackList": {
|
||||
"Tracks": self.get_dbus_playlist(state.play_queue),
|
||||
"CanEditTracks": False,
|
||||
},
|
||||
'org.mpris.MediaPlayer2.Playlists': {
|
||||
'PlaylistCount': playlist_count,
|
||||
'Orderings': ['Alphabetical', 'Created', 'Modified'],
|
||||
'ActivePlaylist': ('(b(oss))', active_playlist),
|
||||
"org.mpris.MediaPlayer2.Playlists": {
|
||||
"PlaylistCount": playlist_count,
|
||||
"Orderings": ["Alphabetical", "Created", "Modified"],
|
||||
"ActivePlaylist": ("(b(oss))", active_playlist),
|
||||
},
|
||||
}
|
||||
|
||||
def get_mpris_metadata(
|
||||
self,
|
||||
idx: int,
|
||||
play_queue: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
def get_mpris_metadata(self, idx: int, play_queue: List[str],) -> Dict[str, Any]:
|
||||
song_result = CacheManager.get_song_details(play_queue[idx])
|
||||
if song_result.is_future:
|
||||
return {}
|
||||
@@ -297,18 +269,18 @@ class DBusManager:
|
||||
|
||||
trackid = self.get_dbus_playlist(play_queue)[idx]
|
||||
duration = (
|
||||
'x',
|
||||
"x",
|
||||
(song.duration or 0) * self.second_microsecond_conversion,
|
||||
)
|
||||
|
||||
return {
|
||||
'mpris:trackid': trackid,
|
||||
'mpris:length': duration,
|
||||
'mpris:artUrl': CacheManager.get_cover_art_url(song.coverArt),
|
||||
'xesam:album': song.album or '',
|
||||
'xesam:albumArtist': [song.artist or ''],
|
||||
'xesam:artist': [song.artist or ''],
|
||||
'xesam:title': song.title,
|
||||
"mpris:trackid": trackid,
|
||||
"mpris:length": duration,
|
||||
"mpris:artUrl": CacheManager.get_cover_art_url(song.coverArt),
|
||||
"xesam:album": song.album or "",
|
||||
"xesam:albumArtist": [song.artist or ""],
|
||||
"xesam:artist": [song.artist or ""],
|
||||
"xesam:title": song.title,
|
||||
}
|
||||
|
||||
def get_dbus_playlist(self, play_queue: List[str]) -> List[str]:
|
||||
@@ -316,7 +288,7 @@ class DBusManager:
|
||||
tracks = []
|
||||
for song_id in play_queue:
|
||||
id_ = seen_counts[song_id]
|
||||
tracks.append(f'/song/{song_id}/{id_}')
|
||||
tracks.append(f"/song/{song_id}/{id_}")
|
||||
seen_counts[song_id] += 1
|
||||
|
||||
return tracks
|
||||
@@ -329,35 +301,36 @@ class DBusManager:
|
||||
|
||||
changes = defaultdict(dict)
|
||||
|
||||
for path, change in diff.get('values_changed', {}).items():
|
||||
for path, change in diff.get("values_changed", {}).items():
|
||||
interface, property_name = self.diff_parse_re.match(path).groups()
|
||||
changes[interface][property_name] = change['new_value']
|
||||
changes[interface][property_name] = change["new_value"]
|
||||
|
||||
if diff.get('dictionary_item_added'):
|
||||
if diff.get("dictionary_item_added"):
|
||||
changes = new_property_dict
|
||||
|
||||
for interface, changed_props in changes.items():
|
||||
# If the metadata has changed, just make the entire Metadata object
|
||||
# part of the update.
|
||||
if 'Metadata' in changed_props.keys():
|
||||
changed_props['Metadata'] = new_property_dict[interface][
|
||||
'Metadata']
|
||||
if "Metadata" in changed_props.keys():
|
||||
changed_props["Metadata"] = new_property_dict[interface]["Metadata"]
|
||||
|
||||
# Special handling for when the position changes (a seek).
|
||||
# Technically, I'm sending this signal too often, but I don't think
|
||||
# it really matters.
|
||||
if (interface == 'org.mpris.MediaPlayer2.Player'
|
||||
and 'Position' in changed_props):
|
||||
if (
|
||||
interface == "org.mpris.MediaPlayer2.Player"
|
||||
and "Position" in changed_props
|
||||
):
|
||||
self.connection.emit_signal(
|
||||
None,
|
||||
'/org/mpris/MediaPlayer2',
|
||||
"/org/mpris/MediaPlayer2",
|
||||
interface,
|
||||
'Seeked',
|
||||
GLib.Variant('(x)', (changed_props['Position'][1], )),
|
||||
"Seeked",
|
||||
GLib.Variant("(x)", (changed_props["Position"][1],)),
|
||||
)
|
||||
|
||||
# Do not emit the property change.
|
||||
del changed_props['Position']
|
||||
del changed_props["Position"]
|
||||
|
||||
# Special handling for when the track list changes.
|
||||
# Technically, I'm supposed to use `TrackAdded` and `TrackRemoved`
|
||||
@@ -370,35 +343,39 @@ class DBusManager:
|
||||
#
|
||||
# So I think that any change is invasive enough that I should use
|
||||
# this signal.
|
||||
if (interface == 'org.mpris.MediaPlayer2.TrackList'
|
||||
and 'Tracks' in changed_props):
|
||||
track_list = changed_props['Tracks']
|
||||
if (
|
||||
interface == "org.mpris.MediaPlayer2.TrackList"
|
||||
and "Tracks" in changed_props
|
||||
):
|
||||
track_list = changed_props["Tracks"]
|
||||
if len(track_list) > 0:
|
||||
current_track = (
|
||||
new_property_dict['org.mpris.MediaPlayer2.Player']
|
||||
['Metadata'].get('mpris:trackid', track_list[0]))
|
||||
current_track = new_property_dict["org.mpris.MediaPlayer2.Player"][
|
||||
"Metadata"
|
||||
].get("mpris:trackid", track_list[0])
|
||||
self.connection.emit_signal(
|
||||
None,
|
||||
'/org/mpris/MediaPlayer2',
|
||||
"/org/mpris/MediaPlayer2",
|
||||
interface,
|
||||
'TrackListReplaced',
|
||||
GLib.Variant('(aoo)', (track_list, current_track)),
|
||||
"TrackListReplaced",
|
||||
GLib.Variant("(aoo)", (track_list, current_track)),
|
||||
)
|
||||
|
||||
self.connection.emit_signal(
|
||||
None,
|
||||
'/org/mpris/MediaPlayer2',
|
||||
'org.freedesktop.DBus.Properties',
|
||||
'PropertiesChanged',
|
||||
"/org/mpris/MediaPlayer2",
|
||||
"org.freedesktop.DBus.Properties",
|
||||
"PropertiesChanged",
|
||||
GLib.Variant(
|
||||
'(sa{sv}as)', (
|
||||
"(sa{sv}as)",
|
||||
(
|
||||
interface,
|
||||
{
|
||||
k: DBusManager.to_variant(v)
|
||||
for k, v in changed_props.items()
|
||||
},
|
||||
[],
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Update state for next diff.
|
||||
|
@@ -8,13 +8,11 @@ from dateutil import parser
|
||||
|
||||
def from_json(template_type: Any, data: Any) -> Any:
|
||||
"""
|
||||
Converts data from a JSON parse into an instantiation of the Python object
|
||||
specified by template_type.
|
||||
Converts data from a JSON parse into an instantiation of the Python object specified
|
||||
by template_type.
|
||||
|
||||
Arguments:
|
||||
|
||||
template_type: the template type to deserialize into
|
||||
data: the data to deserialize to the class
|
||||
:param template_type: the template type to deserialize into
|
||||
:param data: the data to deserialize to the class
|
||||
"""
|
||||
# Approach for deserialization here:
|
||||
# https://stackoverflow.com/a/40639688/2319844
|
||||
@@ -24,8 +22,7 @@ def from_json(template_type: Any, data: Any) -> Any:
|
||||
if isinstance(template_type, typing.ForwardRef): # type: ignore
|
||||
template_type = template_type._evaluate(globals(), locals())
|
||||
|
||||
annotations: Dict[str,
|
||||
Type] = getattr(template_type, '__annotations__', {})
|
||||
annotations: Dict[str, Type] = getattr(template_type, "__annotations__", {})
|
||||
|
||||
# Handle primitive of objects
|
||||
instance: Any = None
|
||||
@@ -33,7 +30,7 @@ def from_json(template_type: Any, data: Any) -> Any:
|
||||
instance = None
|
||||
# Handle generics. List[*], Dict[*, *] in particular.
|
||||
elif type(template_type) == typing._GenericAlias: # type: ignore
|
||||
if getattr(template_type, '__origin__') == typing.Union:
|
||||
if getattr(template_type, "__origin__") == typing.Union:
|
||||
template_type = template_type.__args__[0]
|
||||
instance = from_json(template_type, data)
|
||||
else:
|
||||
@@ -42,11 +39,11 @@ def from_json(template_type: Any, data: Any) -> Any:
|
||||
|
||||
# This is not very elegant since it doesn't allow things which
|
||||
# sublass from List or Dict. For my purposes, this doesn't matter.
|
||||
if class_name == 'List':
|
||||
if class_name == "List":
|
||||
inner_type = template_type.__args__[0]
|
||||
instance = [from_json(inner_type, value) for value in data]
|
||||
|
||||
elif class_name == 'Dict':
|
||||
elif class_name == "Dict":
|
||||
key_type, val_type = template_type.__args__
|
||||
instance = {
|
||||
from_json(key_type, key): from_json(val_type, value)
|
||||
@@ -54,8 +51,10 @@ def from_json(template_type: Any, data: Any) -> Any:
|
||||
}
|
||||
else:
|
||||
raise Exception(
|
||||
'Trying to deserialize an unsupported type: {}'.format(
|
||||
template_type._name))
|
||||
"Trying to deserialize an unsupported type: {}".format(
|
||||
template_type._name
|
||||
)
|
||||
)
|
||||
elif template_type == str or issubclass(template_type, str):
|
||||
instance = data
|
||||
elif template_type == int or issubclass(template_type, int):
|
||||
@@ -64,7 +63,7 @@ def from_json(template_type: Any, data: Any) -> Any:
|
||||
instance = bool(data)
|
||||
elif type(template_type) == EnumMeta:
|
||||
if type(data) == dict:
|
||||
instance = template_type(data.get('_value_'))
|
||||
instance = template_type(data.get("_value_"))
|
||||
else:
|
||||
instance = template_type(data)
|
||||
elif template_type == datetime:
|
||||
@@ -83,6 +82,7 @@ def from_json(template_type: Any, data: Any) -> Any:
|
||||
**{
|
||||
field: from_json(field_type, data.get(field))
|
||||
for field, field_type in annotations.items()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return instance
|
||||
|
@@ -74,52 +74,58 @@ class Player:
|
||||
self._set_is_muted(value)
|
||||
|
||||
def reset(self):
|
||||
raise NotImplementedError(
|
||||
'reset must be implemented by implementor of Player')
|
||||
raise NotImplementedError("reset must be implemented by implementor of Player")
|
||||
|
||||
def play_media(self, file_or_url: str, progress: float, song: Child):
|
||||
raise NotImplementedError(
|
||||
'play_media must be implemented by implementor of Player')
|
||||
"play_media must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def _is_playing(self):
|
||||
raise NotImplementedError(
|
||||
'_is_playing must be implemented by implementor of Player')
|
||||
"_is_playing must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def pause(self):
|
||||
raise NotImplementedError(
|
||||
'pause must be implemented by implementor of Player')
|
||||
raise NotImplementedError("pause must be implemented by implementor of Player")
|
||||
|
||||
def toggle_play(self):
|
||||
raise NotImplementedError(
|
||||
'toggle_play must be implemented by implementor of Player')
|
||||
"toggle_play must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def seek(self, value: float):
|
||||
raise NotImplementedError(
|
||||
'seek must be implemented by implementor of Player')
|
||||
raise NotImplementedError("seek must be implemented by implementor of Player")
|
||||
|
||||
def _get_timepos(self):
|
||||
raise NotImplementedError(
|
||||
'get_timepos must be implemented by implementor of Player')
|
||||
"get_timepos must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def _get_volume(self):
|
||||
raise NotImplementedError(
|
||||
'_get_volume must be implemented by implementor of Player')
|
||||
"_get_volume must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def _set_volume(self, value: float):
|
||||
raise NotImplementedError(
|
||||
'_set_volume must be implemented by implementor of Player')
|
||||
"_set_volume must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def _get_is_muted(self):
|
||||
raise NotImplementedError(
|
||||
'_get_is_muted must be implemented by implementor of Player')
|
||||
"_get_is_muted must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def _set_is_muted(self, value: bool):
|
||||
raise NotImplementedError(
|
||||
'_set_is_muted must be implemented by implementor of Player')
|
||||
"_set_is_muted must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
raise NotImplementedError(
|
||||
'shutdown must be implemented by implementor of Player')
|
||||
"shutdown must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
|
||||
class MPVPlayer(Player):
|
||||
@@ -130,19 +136,18 @@ class MPVPlayer(Player):
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
config: AppConfiguration,
|
||||
):
|
||||
super().__init__(
|
||||
on_timepos_change, on_track_end, on_player_event, config)
|
||||
super().__init__(on_timepos_change, on_track_end, on_player_event, config)
|
||||
|
||||
self.mpv = mpv.MPV()
|
||||
self.mpv.audio_client_name = 'sublime-music'
|
||||
self.mpv.audio_client_name = "sublime-music"
|
||||
self.mpv.replaygain = config.replay_gain.as_string()
|
||||
self.progress_value_lock = threading.Lock()
|
||||
self.progress_value_count = 0
|
||||
self._muted = False
|
||||
self._volume = 100.
|
||||
self._volume = 100.0
|
||||
self._can_hotswap_source = True
|
||||
|
||||
@self.mpv.property_observer('time-pos')
|
||||
@self.mpv.property_observer("time-pos")
|
||||
def time_observer(_: Any, value: Optional[float]):
|
||||
self.on_timepos_change(value)
|
||||
if value is None and self.progress_value_count > 1:
|
||||
@@ -169,10 +174,7 @@ class MPVPlayer(Player):
|
||||
|
||||
self.mpv.pause = False
|
||||
self.mpv.command(
|
||||
'loadfile',
|
||||
file_or_url,
|
||||
'replace',
|
||||
f'start={progress}' if progress else '',
|
||||
"loadfile", file_or_url, "replace", f"start={progress}" if progress else "",
|
||||
)
|
||||
self._song_loaded = True
|
||||
|
||||
@@ -180,10 +182,10 @@ class MPVPlayer(Player):
|
||||
self.mpv.pause = True
|
||||
|
||||
def toggle_play(self):
|
||||
self.mpv.cycle('pause')
|
||||
self.mpv.cycle("pause")
|
||||
|
||||
def seek(self, value: float):
|
||||
self.mpv.seek(str(value), 'absolute')
|
||||
self.mpv.seek(str(value), "absolute")
|
||||
|
||||
def _get_volume(self) -> float:
|
||||
return self._volume
|
||||
@@ -236,31 +238,30 @@ class ChromecastPlayer(Player):
|
||||
|
||||
self.app = bottle.Bottle()
|
||||
|
||||
@self.app.route('/')
|
||||
@self.app.route("/")
|
||||
def index() -> str:
|
||||
return '''
|
||||
return """
|
||||
<h1>Sublime Music Local Music Server</h1>
|
||||
<p>
|
||||
Sublime Music uses this port as a server for serving music
|
||||
Chromecasts on the same LAN.
|
||||
</p>
|
||||
'''
|
||||
"""
|
||||
|
||||
@self.app.route('/s/<token>')
|
||||
@self.app.route("/s/<token>")
|
||||
def stream_song(token: str) -> bytes:
|
||||
if token != self.token:
|
||||
raise bottle.HTTPError(status=401, body='Invalid token.')
|
||||
raise bottle.HTTPError(status=401, body="Invalid token.")
|
||||
|
||||
song = CacheManager.get_song_details(self.song_id).result()
|
||||
filename, _ = CacheManager.get_song_filename_or_stream(song)
|
||||
with open(filename, 'rb') as fin:
|
||||
with open(filename, "rb") as fin:
|
||||
song_buffer = io.BytesIO(fin.read())
|
||||
|
||||
bottle.response.set_header(
|
||||
'Content-Type',
|
||||
mimetypes.guess_type(filename)[0],
|
||||
"Content-Type", mimetypes.guess_type(filename)[0],
|
||||
)
|
||||
bottle.response.set_header('Accept-Ranges', 'bytes')
|
||||
bottle.response.set_header("Accept-Ranges", "bytes")
|
||||
return song_buffer.read()
|
||||
|
||||
def set_song_and_token(self, song_id: str, token: str):
|
||||
@@ -276,11 +277,11 @@ class ChromecastPlayer(Player):
|
||||
def get_chromecasts(cls) -> Future:
|
||||
def do_get_chromecasts() -> List[pychromecast.Chromecast]:
|
||||
if not ChromecastPlayer.getting_chromecasts:
|
||||
logging.info('Getting Chromecasts')
|
||||
logging.info("Getting Chromecasts")
|
||||
ChromecastPlayer.getting_chromecasts = True
|
||||
ChromecastPlayer.chromecasts = pychromecast.get_chromecasts()
|
||||
else:
|
||||
logging.info('Already getting Chromecasts... busy wait')
|
||||
logging.info("Already getting Chromecasts... busy wait")
|
||||
while ChromecastPlayer.getting_chromecasts:
|
||||
sleep(0.1)
|
||||
|
||||
@@ -291,15 +292,15 @@ class ChromecastPlayer(Player):
|
||||
|
||||
def set_playing_chromecast(self, uuid: str):
|
||||
self.chromecast = next(
|
||||
cc for cc in ChromecastPlayer.chromecasts
|
||||
if cc.device.uuid == UUID(uuid))
|
||||
cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid)
|
||||
)
|
||||
|
||||
self.chromecast.media_controller.register_status_listener(
|
||||
ChromecastPlayer.media_status_listener)
|
||||
self.chromecast.register_status_listener(
|
||||
ChromecastPlayer.cast_status_listener)
|
||||
ChromecastPlayer.media_status_listener
|
||||
)
|
||||
self.chromecast.register_status_listener(ChromecastPlayer.cast_status_listener)
|
||||
self.chromecast.wait()
|
||||
logging.info(f'Using: {self.chromecast.device.friendly_name}')
|
||||
logging.info(f"Using: {self.chromecast.device.friendly_name}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -308,16 +309,17 @@ class ChromecastPlayer(Player):
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
config: AppConfiguration,
|
||||
):
|
||||
super().__init__(
|
||||
on_timepos_change, on_track_end, on_player_event, config)
|
||||
super().__init__(on_timepos_change, on_track_end, on_player_event, config)
|
||||
self._timepos = 0.0
|
||||
self.time_incrementor_running = False
|
||||
self._can_hotswap_source = False
|
||||
|
||||
ChromecastPlayer.cast_status_listener.on_new_cast_status = (
|
||||
self.on_new_cast_status)
|
||||
self.on_new_cast_status
|
||||
)
|
||||
ChromecastPlayer.media_status_listener.on_new_media_status = (
|
||||
self.on_new_media_status)
|
||||
self.on_new_media_status
|
||||
)
|
||||
|
||||
# Set host_ip
|
||||
# TODO (#128): should have a mechanism to update this. Maybe it should
|
||||
@@ -326,7 +328,7 @@ class ChromecastPlayer(Player):
|
||||
# piped over the VPN tunnel.
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
s.connect(("8.8.8.8", 80))
|
||||
self.host_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except OSError:
|
||||
@@ -336,42 +338,43 @@ class ChromecastPlayer(Player):
|
||||
self.serve_over_lan = config.serve_over_lan
|
||||
|
||||
if self.serve_over_lan:
|
||||
self.server_thread = ChromecastPlayer.ServerThread(
|
||||
'0.0.0.0', self.port)
|
||||
self.server_thread = ChromecastPlayer.ServerThread("0.0.0.0", self.port)
|
||||
self.server_thread.start()
|
||||
|
||||
def on_new_cast_status(
|
||||
self,
|
||||
status: pychromecast.socket_client.CastStatus,
|
||||
self, status: pychromecast.socket_client.CastStatus,
|
||||
):
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
'volume_change',
|
||||
"volume_change",
|
||||
status.volume_level * 100 if not status.volume_muted else 0,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
# This normally happens when "Stop Casting" is pressed in the Google
|
||||
# Home app.
|
||||
if status.session_id is None:
|
||||
self.on_player_event(PlayerEvent('play_state_change', False))
|
||||
self.on_player_event(PlayerEvent("play_state_change", False))
|
||||
self._song_loaded = False
|
||||
|
||||
def on_new_media_status(
|
||||
self,
|
||||
status: pychromecast.controllers.media.MediaStatus,
|
||||
self, status: pychromecast.controllers.media.MediaStatus,
|
||||
):
|
||||
# Detect the end of a track and go to the next one.
|
||||
if (status.idle_reason == 'FINISHED' and status.player_state == 'IDLE'
|
||||
and self._timepos > 0):
|
||||
if (
|
||||
status.idle_reason == "FINISHED"
|
||||
and status.player_state == "IDLE"
|
||||
and self._timepos > 0
|
||||
):
|
||||
self.on_track_end()
|
||||
|
||||
self._timepos = status.current_time
|
||||
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
'play_state_change',
|
||||
status.player_state in ('PLAYING', 'BUFFERING'),
|
||||
))
|
||||
"play_state_change", status.player_state in ("PLAYING", "BUFFERING"),
|
||||
)
|
||||
)
|
||||
|
||||
# Start the time incrementor just in case this was a play notification.
|
||||
self.start_time_incrementor()
|
||||
@@ -400,8 +403,7 @@ class ChromecastPlayer(Player):
|
||||
if self.playing:
|
||||
break
|
||||
if url is not None:
|
||||
if (url == self.chromecast.media_controller.status
|
||||
.content_id):
|
||||
if url == self.chromecast.media_controller.status.content_id:
|
||||
break
|
||||
|
||||
callback()
|
||||
@@ -421,30 +423,29 @@ class ChromecastPlayer(Player):
|
||||
# If it's a local file, then see if we can serve it over the LAN.
|
||||
if not stream_scheme:
|
||||
if self.serve_over_lan:
|
||||
token = base64.b64encode(os.urandom(64)).decode('ascii')
|
||||
for r in (('+', '.'), ('/', '-'), ('=', '_')):
|
||||
token = base64.b64encode(os.urandom(64)).decode("ascii")
|
||||
for r in (("+", "."), ("/", "-"), ("=", "_")):
|
||||
token = token.replace(*r)
|
||||
self.server_thread.set_song_and_token(song.id, token)
|
||||
file_or_url = f'http://{self.host_ip}:{self.port}/s/{token}'
|
||||
file_or_url = f"http://{self.host_ip}:{self.port}/s/{token}"
|
||||
else:
|
||||
file_or_url, _ = CacheManager.get_song_filename_or_stream(
|
||||
song,
|
||||
force_stream=True,
|
||||
song, force_stream=True,
|
||||
)
|
||||
|
||||
cover_art_url = CacheManager.get_cover_art_url(song.coverArt)
|
||||
self.chromecast.media_controller.play_media(
|
||||
file_or_url,
|
||||
# Just pretend that whatever we send it is mp3, even if it isn't.
|
||||
'audio/mp3',
|
||||
"audio/mp3",
|
||||
current_time=progress,
|
||||
title=song.title,
|
||||
thumb=cover_art_url,
|
||||
metadata={
|
||||
'metadataType': 3,
|
||||
'albumName': song.album,
|
||||
'artist': song.artist,
|
||||
'trackNumber': song.track,
|
||||
"metadataType": 3,
|
||||
"albumName": song.album,
|
||||
"artist": song.artist,
|
||||
"trackNumber": song.track,
|
||||
},
|
||||
)
|
||||
self._timepos = progress
|
||||
|
@@ -3,4 +3,4 @@ This module defines a stateless server which interops with the Subsonic API.
|
||||
"""
|
||||
from .server import Server
|
||||
|
||||
__all__ = ('Server', )
|
||||
__all__ = ("Server",)
|
||||
|
@@ -6,15 +6,15 @@ from sublime.from_json import from_json as _from_json
|
||||
|
||||
class APIObject:
|
||||
"""Defines the base class for objects coming from the Subsonic API."""
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Creates an :class:`APIObject` by taking the ``data`` and passing it to
|
||||
the class constructor and then recursively calling ``from_json`` on all
|
||||
of the fields. ``data`` just has to be a well-formed :class:`dict`, so
|
||||
it can come from the JSON or XML APIs.
|
||||
Creates an :class:`APIObject` by taking the ``data`` and passing it to the class
|
||||
constructor and then recursively calling ``from_json`` on all of the fields.
|
||||
``data`` just has to be a well-formed :class:`dict`, so it can come from the
|
||||
JSON or XML APIs.
|
||||
|
||||
:param data: a Python dictionary representation of the data to
|
||||
deserialize
|
||||
:param data: a Python dictionary representation of the data to deserialize
|
||||
"""
|
||||
return _from_json(cls, data)
|
||||
|
@@ -33,10 +33,10 @@ class AverageRating(APIObject, float):
|
||||
|
||||
|
||||
class MediaType(APIObject, Enum):
|
||||
MUSIC = 'music'
|
||||
PODCAST = 'podcast'
|
||||
AUDIOBOOK = 'audiobook'
|
||||
VIDEO = 'video'
|
||||
MUSIC = "music"
|
||||
PODCAST = "podcast"
|
||||
AUDIOBOOK = "audiobook"
|
||||
VIDEO = "video"
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return getattr(self, key, default)
|
||||
@@ -451,12 +451,12 @@ class MusicFolders(APIObject):
|
||||
|
||||
|
||||
class PodcastStatus(APIObject, Enum):
|
||||
NEW = 'new'
|
||||
DOWNLOADING = 'downloading'
|
||||
COMPLETED = 'completed'
|
||||
ERROR = 'error'
|
||||
DELETED = 'deleted'
|
||||
SKIPPED = 'skipped'
|
||||
NEW = "new"
|
||||
DOWNLOADING = "downloading"
|
||||
COMPLETED = "completed"
|
||||
ERROR = "error"
|
||||
DELETED = "deleted"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return getattr(self, key, default)
|
||||
@@ -656,8 +656,8 @@ class Podcasts(APIObject):
|
||||
|
||||
|
||||
class ResponseStatus(APIObject, Enum):
|
||||
OK = 'ok'
|
||||
FAILED = 'failed'
|
||||
OK = "ok"
|
||||
FAILED = "failed"
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return getattr(self, key, default)
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@ import datetime
|
||||
from typing import Any, Callable, Iterable, Optional, Tuple, Union
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
|
||||
|
||||
from sublime.cache_manager import CacheManager
|
||||
@@ -16,12 +17,12 @@ Album = Union[Child, AlbumWithSongsID3]
|
||||
|
||||
class AlbumsPanel(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -37,18 +38,18 @@ class AlbumsPanel(Gtk.Box):
|
||||
actionbar = Gtk.ActionBar()
|
||||
|
||||
# Sort by
|
||||
actionbar.add(Gtk.Label(label='Sort'))
|
||||
actionbar.add(Gtk.Label(label="Sort"))
|
||||
self.sort_type_combo = self.make_combobox(
|
||||
(
|
||||
('random', 'randomly'),
|
||||
('byGenre', 'by genre'),
|
||||
('newest', 'by most recently added'),
|
||||
('highest', 'by highest rated'),
|
||||
('frequent', 'by most played'),
|
||||
('recent', 'by most recently played'),
|
||||
('alphabetical', 'alphabetically'),
|
||||
('starred', 'by starred only'),
|
||||
('byYear', 'by year'),
|
||||
("random", "randomly"),
|
||||
("byGenre", "by genre"),
|
||||
("newest", "by most recently added"),
|
||||
("highest", "by highest rated"),
|
||||
("frequent", "by most played"),
|
||||
("recent", "by most recently played"),
|
||||
("alphabetical", "alphabetically"),
|
||||
("starred", "by starred only"),
|
||||
("byYear", "by year"),
|
||||
),
|
||||
self.on_type_combo_changed,
|
||||
)
|
||||
@@ -56,10 +57,7 @@ class AlbumsPanel(Gtk.Box):
|
||||
|
||||
# Alphabetically how?
|
||||
self.alphabetical_type_combo = self.make_combobox(
|
||||
(
|
||||
('name', 'by album name'),
|
||||
('artist', 'by artist name'),
|
||||
),
|
||||
(("name", "by album name"), ("artist", "by artist name"),),
|
||||
self.on_alphabetical_type_change,
|
||||
)
|
||||
actionbar.pack_start(self.alphabetical_type_combo)
|
||||
@@ -70,23 +68,20 @@ class AlbumsPanel(Gtk.Box):
|
||||
|
||||
next_decade = datetime.datetime.now().year + 10
|
||||
|
||||
self.from_year_label = Gtk.Label(label='from')
|
||||
self.from_year_label = Gtk.Label(label="from")
|
||||
actionbar.pack_start(self.from_year_label)
|
||||
self.from_year_spin_button = Gtk.SpinButton.new_with_range(
|
||||
0, next_decade, 1)
|
||||
self.from_year_spin_button.connect(
|
||||
'value-changed', self.on_year_changed)
|
||||
self.from_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
||||
self.from_year_spin_button.connect("value-changed", self.on_year_changed)
|
||||
actionbar.pack_start(self.from_year_spin_button)
|
||||
|
||||
self.to_year_label = Gtk.Label(label='to')
|
||||
self.to_year_label = Gtk.Label(label="to")
|
||||
actionbar.pack_start(self.to_year_label)
|
||||
self.to_year_spin_button = Gtk.SpinButton.new_with_range(
|
||||
0, next_decade, 1)
|
||||
self.to_year_spin_button.connect('value-changed', self.on_year_changed)
|
||||
self.to_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
||||
self.to_year_spin_button.connect("value-changed", self.on_year_changed)
|
||||
actionbar.pack_start(self.to_year_spin_button)
|
||||
|
||||
refresh = IconButton('view-refresh-symbolic', 'Refresh list of albums')
|
||||
refresh.connect('clicked', self.on_refresh_clicked)
|
||||
refresh = IconButton("view-refresh-symbolic", "Refresh list of albums")
|
||||
refresh.connect("clicked", self.on_refresh_clicked)
|
||||
actionbar.pack_end(refresh)
|
||||
|
||||
self.add(actionbar)
|
||||
@@ -94,17 +89,16 @@ class AlbumsPanel(Gtk.Box):
|
||||
scrolled_window = Gtk.ScrolledWindow()
|
||||
self.grid = AlbumsGrid()
|
||||
self.grid.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
self.grid.connect('cover-clicked', self.on_grid_cover_clicked)
|
||||
self.grid.connect("cover-clicked", self.on_grid_cover_clicked)
|
||||
scrolled_window.add(self.grid)
|
||||
self.add(scrolled_window)
|
||||
|
||||
def make_combobox(
|
||||
self,
|
||||
items: Iterable[Tuple[str, str]],
|
||||
on_change: Callable[['AlbumsPanel', Gtk.ComboBox], None],
|
||||
on_change: Callable[["AlbumsPanel", Gtk.ComboBox], None],
|
||||
) -> Gtk.ComboBox:
|
||||
store = Gtk.ListStore(str, str)
|
||||
for item in items:
|
||||
@@ -112,53 +106,46 @@ class AlbumsPanel(Gtk.Box):
|
||||
|
||||
combo = Gtk.ComboBox.new_with_model(store)
|
||||
combo.set_id_column(0)
|
||||
combo.connect('changed', on_change)
|
||||
combo.connect("changed", on_change)
|
||||
|
||||
renderer_text = Gtk.CellRendererText()
|
||||
combo.pack_start(renderer_text, True)
|
||||
combo.add_attribute(renderer_text, 'text', 1)
|
||||
combo.add_attribute(renderer_text, "text", 1)
|
||||
|
||||
return combo
|
||||
|
||||
def populate_genre_combo(
|
||||
self,
|
||||
app_config: AppConfiguration,
|
||||
force: bool = False,
|
||||
self, app_config: AppConfiguration, force: bool = False,
|
||||
):
|
||||
if not CacheManager.ready():
|
||||
return
|
||||
|
||||
def get_genres_done(f: CacheManager.Result):
|
||||
try:
|
||||
new_store = [
|
||||
(genre.value, genre.value) for genre in (f.result() or [])
|
||||
]
|
||||
new_store = [(genre.value, genre.value) for genre in (f.result() or [])]
|
||||
|
||||
util.diff_song_store(self.genre_combo.get_model(), new_store)
|
||||
|
||||
current_genre_id = self.get_id(self.genre_combo)
|
||||
if current_genre_id != app_config.state.current_album_genre:
|
||||
self.genre_combo.set_active_id(
|
||||
app_config.state.current_album_genre)
|
||||
self.genre_combo.set_active_id(app_config.state.current_album_genre)
|
||||
finally:
|
||||
self.updating_query = False
|
||||
|
||||
# Never force. We invalidate the cache ourselves (force is used when
|
||||
# sort params change).
|
||||
genres_future = CacheManager.get_genres(force=False)
|
||||
genres_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(get_genres_done, f))
|
||||
genres_future.add_done_callback(lambda f: GLib.idle_add(get_genres_done, f))
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self.updating_query = True
|
||||
|
||||
self.sort_type_combo.set_active_id(app_config.state.current_album_sort)
|
||||
self.alphabetical_type_combo.set_active_id(
|
||||
app_config.state.current_album_alphabetical_sort)
|
||||
self.from_year_spin_button.set_value(
|
||||
app_config.state.current_album_from_year)
|
||||
self.to_year_spin_button.set_value(
|
||||
app_config.state.current_album_to_year)
|
||||
app_config.state.current_album_alphabetical_sort
|
||||
)
|
||||
self.from_year_spin_button.set_value(app_config.state.current_album_from_year)
|
||||
self.to_year_spin_button.set_value(app_config.state.current_album_to_year)
|
||||
self.populate_genre_combo(app_config, force=force)
|
||||
|
||||
# Show/hide the combo boxes.
|
||||
@@ -169,10 +156,10 @@ class AlbumsPanel(Gtk.Box):
|
||||
else:
|
||||
element.hide()
|
||||
|
||||
show_if('alphabetical', self.alphabetical_type_combo)
|
||||
show_if('byGenre', self.genre_combo)
|
||||
show_if('byYear', self.from_year_label, self.from_year_spin_button)
|
||||
show_if('byYear', self.to_year_label, self.to_year_spin_button)
|
||||
show_if("alphabetical", self.alphabetical_type_combo)
|
||||
show_if("byGenre", self.genre_combo)
|
||||
show_if("byYear", self.from_year_label, self.from_year_spin_button)
|
||||
show_if("byYear", self.to_year_label, self.to_year_spin_button)
|
||||
|
||||
self.grid.update(self.grid_order_token, app_config, force=force)
|
||||
|
||||
@@ -183,24 +170,23 @@ class AlbumsPanel(Gtk.Box):
|
||||
return None
|
||||
|
||||
def on_refresh_clicked(self, button: Any):
|
||||
self.emit('refresh-window', {}, True)
|
||||
self.emit("refresh-window", {}, True)
|
||||
|
||||
def on_type_combo_changed(self, combo: Gtk.ComboBox):
|
||||
new_active_sort = self.get_id(combo)
|
||||
self.grid_order_token = self.grid.update_params(type_=new_active_sort)
|
||||
self.emit_if_not_updating(
|
||||
'refresh-window',
|
||||
{'current_album_sort': new_active_sort},
|
||||
False,
|
||||
"refresh-window", {"current_album_sort": new_active_sort}, False,
|
||||
)
|
||||
|
||||
def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
|
||||
new_active_alphabetical_sort = self.get_id(combo)
|
||||
self.grid_order_token = self.grid.update_params(
|
||||
alphabetical_type=new_active_alphabetical_sort)
|
||||
alphabetical_type=new_active_alphabetical_sort
|
||||
)
|
||||
self.emit_if_not_updating(
|
||||
'refresh-window',
|
||||
{'current_album_alphabetical_sort': new_active_alphabetical_sort},
|
||||
"refresh-window",
|
||||
{"current_album_alphabetical_sort": new_active_alphabetical_sort},
|
||||
False,
|
||||
)
|
||||
|
||||
@@ -208,9 +194,7 @@ class AlbumsPanel(Gtk.Box):
|
||||
new_active_genre = self.get_id(combo)
|
||||
self.grid_order_token = self.grid.update_params(genre=new_active_genre)
|
||||
self.emit_if_not_updating(
|
||||
'refresh-window',
|
||||
{'current_album_genre': new_active_genre},
|
||||
True,
|
||||
"refresh-window", {"current_album_genre": new_active_genre}, True,
|
||||
)
|
||||
|
||||
def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
|
||||
@@ -219,25 +203,19 @@ class AlbumsPanel(Gtk.Box):
|
||||
if self.to_year_spin_button == entry:
|
||||
self.grid_order_token = self.grid.update_params(to_year=year)
|
||||
self.emit_if_not_updating(
|
||||
'refresh-window',
|
||||
{'current_album_to_year': year},
|
||||
True,
|
||||
"refresh-window", {"current_album_to_year": year}, True,
|
||||
)
|
||||
else:
|
||||
self.grid_order_token = self.grid.update_params(from_year=year)
|
||||
self.emit_if_not_updating(
|
||||
'refresh-window',
|
||||
{'current_album_from_year': year},
|
||||
True,
|
||||
"refresh-window", {"current_album_from_year": year}, True,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def on_grid_cover_clicked(self, grid: Any, id: str):
|
||||
self.emit(
|
||||
'refresh-window',
|
||||
{'selected_album_id': id},
|
||||
False,
|
||||
"refresh-window", {"selected_album_id": id}, False,
|
||||
)
|
||||
|
||||
def emit_if_not_updating(self, *args):
|
||||
@@ -248,23 +226,20 @@ class AlbumsPanel(Gtk.Box):
|
||||
|
||||
class AlbumsGrid(Gtk.Overlay):
|
||||
"""Defines the albums panel."""
|
||||
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'cover-clicked': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, ),
|
||||
),
|
||||
"cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
|
||||
}
|
||||
type_: str = 'random'
|
||||
alphabetical_type: str = 'name'
|
||||
type_: str = "random"
|
||||
alphabetical_type: str = "name"
|
||||
from_year: int = 2010
|
||||
to_year: int = 2020
|
||||
genre: str = 'Rock'
|
||||
genre: str = "Rock"
|
||||
latest_applied_order_ratchet: int = 0
|
||||
order_ratchet: int = 0
|
||||
|
||||
@@ -283,20 +258,24 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
return self.album.id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<AlbumsGrid.AlbumModel {self.album}>'
|
||||
return f"<AlbumsGrid.AlbumModel {self.album}>"
|
||||
|
||||
def update_params(
|
||||
self,
|
||||
type_: str = None,
|
||||
alphabetical_type: str = None,
|
||||
from_year: int = None,
|
||||
to_year: int = None,
|
||||
genre: str = None,
|
||||
self,
|
||||
type_: str = None,
|
||||
alphabetical_type: str = None,
|
||||
from_year: int = None,
|
||||
to_year: int = None,
|
||||
genre: str = None,
|
||||
) -> int:
|
||||
# If there's a diff, increase the ratchet.
|
||||
if (self.type_ != type_ or self.alphabetical_type != alphabetical_type
|
||||
or self.from_year != from_year or self.to_year != to_year
|
||||
or self.genre != genre):
|
||||
if (
|
||||
self.type_ != type_
|
||||
or self.alphabetical_type != alphabetical_type
|
||||
or self.from_year != from_year
|
||||
or self.to_year != to_year
|
||||
or self.genre != genre
|
||||
):
|
||||
self.order_ratchet += 1
|
||||
self.type_ = type_ or self.type_
|
||||
self.alphabetical_type = alphabetical_type or self.alphabetical_type
|
||||
@@ -328,8 +307,8 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
halign=Gtk.Align.CENTER,
|
||||
selection_mode=Gtk.SelectionMode.SINGLE,
|
||||
)
|
||||
self.grid_top.connect('child-activated', self.on_child_activated)
|
||||
self.grid_top.connect('size-allocate', self.on_grid_resize)
|
||||
self.grid_top.connect("child-activated", self.on_child_activated)
|
||||
self.grid_top.connect("size-allocate", self.on_grid_resize)
|
||||
|
||||
self.list_store_top = Gio.ListStore()
|
||||
self.grid_top.bind_model(self.list_store_top, self.create_widget)
|
||||
@@ -357,7 +336,7 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
halign=Gtk.Align.CENTER,
|
||||
selection_mode=Gtk.SelectionMode.SINGLE,
|
||||
)
|
||||
self.grid_bottom.connect('child-activated', self.on_child_activated)
|
||||
self.grid_bottom.connect("child-activated", self.on_child_activated)
|
||||
|
||||
self.list_store_bottom = Gio.ListStore()
|
||||
self.grid_bottom.bind_model(self.list_store_bottom, self.create_widget)
|
||||
@@ -368,7 +347,7 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
self.add(scrolled_window)
|
||||
|
||||
self.spinner = Gtk.Spinner(
|
||||
name='grid-spinner',
|
||||
name="grid-spinner",
|
||||
active=True,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
@@ -376,10 +355,7 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
self.add_overlay(self.spinner)
|
||||
|
||||
def update(
|
||||
self,
|
||||
order_token: int,
|
||||
app_config: AppConfiguration,
|
||||
force: bool = False,
|
||||
self, order_token: int, app_config: AppConfiguration, force: bool = False,
|
||||
):
|
||||
if order_token < self.latest_applied_order_ratchet:
|
||||
return
|
||||
@@ -397,16 +373,13 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
|
||||
# Update the detail panel.
|
||||
children = self.detail_box_inner.get_children()
|
||||
if len(children) > 0 and hasattr(children[0], 'update'):
|
||||
if len(children) > 0 and hasattr(children[0], "update"):
|
||||
children[0].update(force=force)
|
||||
|
||||
error_dialog = None
|
||||
|
||||
def update_grid(
|
||||
self,
|
||||
order_token: int,
|
||||
force: bool = False,
|
||||
selected_id: str = None,
|
||||
self, order_token: int, force: bool = False, selected_id: str = None,
|
||||
):
|
||||
if not CacheManager.ready():
|
||||
self.spinner.hide()
|
||||
@@ -414,11 +387,8 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
|
||||
# Calculate the type.
|
||||
type_ = self.type_
|
||||
if self.type_ == 'alphabetical':
|
||||
type_ += {
|
||||
'name': 'ByName',
|
||||
'artist': 'ByArtist',
|
||||
}[self.alphabetical_type]
|
||||
if self.type_ == "alphabetical":
|
||||
type_ += {"name": "ByName", "artist": "ByArtist"}[self.alphabetical_type]
|
||||
|
||||
def do_update(f: CacheManager.Result):
|
||||
try:
|
||||
@@ -431,11 +401,12 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
transient_for=self.get_toplevel(),
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text='Failed to retrieve albums',
|
||||
text="Failed to retrieve albums",
|
||||
)
|
||||
self.error_dialog.format_secondary_markup(
|
||||
f'Getting albums by {type_} failed due to the following'
|
||||
f' error\n\n{e}')
|
||||
f"Getting albums by {type_} failed due to the following"
|
||||
f" error\n\n{e}"
|
||||
)
|
||||
self.error_dialog.run()
|
||||
self.error_dialog.destroy()
|
||||
self.error_dialog = None
|
||||
@@ -447,8 +418,8 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
return
|
||||
|
||||
should_reload = (
|
||||
force
|
||||
or self.latest_applied_order_ratchet < self.order_ratchet)
|
||||
force or self.latest_applied_order_ratchet < self.order_ratchet
|
||||
)
|
||||
self.latest_applied_order_ratchet = self.order_ratchet
|
||||
|
||||
self.list_store.remove_all()
|
||||
@@ -484,13 +455,14 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
# =========================================================================
|
||||
def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget):
|
||||
click_top = flowbox == self.grid_top
|
||||
selected_index = (
|
||||
child.get_index() + (0 if click_top else len(self.list_store_top)))
|
||||
selected_index = child.get_index() + (
|
||||
0 if click_top else len(self.list_store_top)
|
||||
)
|
||||
|
||||
if click_top and selected_index == self.current_selection:
|
||||
self.emit('cover-clicked', None)
|
||||
self.emit("cover-clicked", None)
|
||||
else:
|
||||
self.emit('cover-clicked', self.list_store[selected_index].id)
|
||||
self.emit("cover-clicked", self.list_store[selected_index].id)
|
||||
|
||||
def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle):
|
||||
# TODO (#124): this doesn't work with themes that add extra padding.
|
||||
@@ -500,22 +472,21 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
if new_items_per_row != self.items_per_row:
|
||||
self.items_per_row = min((rect.width // 230), 7)
|
||||
self.detail_box_inner.set_size_request(
|
||||
self.items_per_row * 230 - 10,
|
||||
-1,
|
||||
self.items_per_row * 230 - 10, -1,
|
||||
)
|
||||
|
||||
self.reflow_grids()
|
||||
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
def create_widget(self, item: 'AlbumsGrid.AlbumModel') -> Gtk.Box:
|
||||
def create_widget(self, item: "AlbumsGrid.AlbumModel") -> Gtk.Box:
|
||||
widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# Cover art image
|
||||
artwork = SpinnerImage(
|
||||
loading=False,
|
||||
image_name='grid-artwork',
|
||||
spinner_name='grid-artwork-spinner',
|
||||
image_name="grid-artwork",
|
||||
spinner_name="grid-artwork-spinner",
|
||||
image_size=200,
|
||||
)
|
||||
widget_box.pack_start(artwork, False, False, 0)
|
||||
@@ -532,16 +503,16 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
|
||||
# Header for the widget
|
||||
header_text = (
|
||||
item.album.title
|
||||
if isinstance(item.album, Child) else item.album.name)
|
||||
item.album.title if isinstance(item.album, Child) else item.album.name
|
||||
)
|
||||
|
||||
header_label = make_label(header_text, 'grid-header-label')
|
||||
header_label = make_label(header_text, "grid-header-label")
|
||||
widget_box.pack_start(header_label, False, False, 0)
|
||||
|
||||
# Extra info for the widget
|
||||
info_text = util.dot_join(item.album.artist, item.album.year)
|
||||
if info_text:
|
||||
info_label = make_label(info_text, 'grid-info-label')
|
||||
info_label = make_label(info_text, "grid-info-label")
|
||||
widget_box.pack_start(info_label, False, False, 0)
|
||||
|
||||
# Download the cover art.
|
||||
@@ -553,26 +524,24 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
artwork.set_loading(True)
|
||||
|
||||
cover_art_filename_future = CacheManager.get_cover_art_filename(
|
||||
item.album.coverArt,
|
||||
before_download=lambda: GLib.idle_add(start_loading),
|
||||
item.album.coverArt, before_download=lambda: GLib.idle_add(start_loading),
|
||||
)
|
||||
cover_art_filename_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(on_artwork_downloaded, f))
|
||||
lambda f: GLib.idle_add(on_artwork_downloaded, f)
|
||||
)
|
||||
|
||||
widget_box.show_all()
|
||||
return widget_box
|
||||
|
||||
def reflow_grids(
|
||||
self,
|
||||
force_reload_from_master: bool = False,
|
||||
selection_changed: bool = False,
|
||||
self, force_reload_from_master: bool = False, selection_changed: bool = False,
|
||||
):
|
||||
# Determine where the cuttoff is between the top and bottom grids.
|
||||
entries_before_fold = len(self.list_store)
|
||||
if self.current_selection is not None and self.items_per_row:
|
||||
entries_before_fold = (
|
||||
((self.current_selection // self.items_per_row) + 1)
|
||||
* self.items_per_row)
|
||||
(self.current_selection // self.items_per_row) + 1
|
||||
) * self.items_per_row
|
||||
|
||||
if force_reload_from_master:
|
||||
# Just remove everything and re-add all of the items.
|
||||
@@ -606,8 +575,7 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
del self.list_store_top[-1]
|
||||
|
||||
if self.current_selection is not None:
|
||||
to_select = self.grid_top.get_child_at_index(
|
||||
self.current_selection)
|
||||
to_select = self.grid_top.get_child_at_index(self.current_selection)
|
||||
if not to_select:
|
||||
return
|
||||
self.grid_top.select_child(to_select)
|
||||
@@ -621,10 +589,9 @@ class AlbumsGrid(Gtk.Overlay):
|
||||
model = self.list_store[self.current_selection]
|
||||
detail_element = AlbumWithSongs(model.album, cover_art_size=300)
|
||||
detail_element.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
detail_element.connect('song-selected', lambda *a: None)
|
||||
detail_element.connect("song-selected", lambda *a: None)
|
||||
|
||||
self.detail_box_inner.pack_start(detail_element, True, True, 0)
|
||||
self.detail_box.show_all()
|
||||
|
@@ -2,7 +2,8 @@ from random import randint
|
||||
from typing import Any, cast, List, Union
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gio, GLib, GObject, Gtk, Pango
|
||||
|
||||
from sublime.cache_manager import CacheManager
|
||||
@@ -22,12 +23,12 @@ class ArtistsPanel(Gtk.Paned):
|
||||
"""Defines the arist panel."""
|
||||
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -42,8 +43,7 @@ class ArtistsPanel(Gtk.Paned):
|
||||
|
||||
self.artist_detail_panel = ArtistDetailPanel()
|
||||
self.artist_detail_panel.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
self.pack2(self.artist_detail_panel, True, False)
|
||||
|
||||
@@ -70,16 +70,15 @@ class ArtistList(Gtk.Box):
|
||||
|
||||
list_actions = Gtk.ActionBar()
|
||||
|
||||
refresh = IconButton(
|
||||
'view-refresh-symbolic', 'Refresh list of artists')
|
||||
refresh.connect('clicked', lambda *a: self.update(force=True))
|
||||
refresh = IconButton("view-refresh-symbolic", "Refresh list of artists")
|
||||
refresh.connect("clicked", lambda *a: self.update(force=True))
|
||||
list_actions.pack_end(refresh)
|
||||
|
||||
self.add(list_actions)
|
||||
|
||||
self.loading_indicator = Gtk.ListBox()
|
||||
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False)
|
||||
spinner = Gtk.Spinner(name='artist-list-spinner', active=True)
|
||||
spinner = Gtk.Spinner(name="artist-list-spinner", active=True)
|
||||
spinner_row.add(spinner)
|
||||
self.loading_indicator.add(spinner_row)
|
||||
self.pack_start(self.loading_indicator, False, False, 0)
|
||||
@@ -87,32 +86,33 @@ class ArtistList(Gtk.Box):
|
||||
list_scroll_window = Gtk.ScrolledWindow(min_content_width=250)
|
||||
|
||||
def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow:
|
||||
label_text = [f'<b>{util.esc(model.name)}</b>']
|
||||
label_text = [f"<b>{util.esc(model.name)}</b>"]
|
||||
|
||||
album_count = model.album_count
|
||||
if album_count:
|
||||
label_text.append(
|
||||
'{} {}'.format(
|
||||
album_count, util.pluralize('album', album_count)))
|
||||
"{} {}".format(album_count, util.pluralize("album", album_count))
|
||||
)
|
||||
|
||||
row = Gtk.ListBoxRow(
|
||||
action_name='app.go-to-artist',
|
||||
action_target=GLib.Variant('s', model.artist_id),
|
||||
action_name="app.go-to-artist",
|
||||
action_target=GLib.Variant("s", model.artist_id),
|
||||
)
|
||||
row.add(
|
||||
Gtk.Label(
|
||||
label='\n'.join(label_text),
|
||||
label="\n".join(label_text),
|
||||
use_markup=True,
|
||||
margin=12,
|
||||
halign=Gtk.Align.START,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
max_width_chars=30,
|
||||
))
|
||||
)
|
||||
)
|
||||
row.show_all()
|
||||
return row
|
||||
|
||||
self.artists_store = Gio.ListStore()
|
||||
self.list = Gtk.ListBox(name='artist-list')
|
||||
self.list = Gtk.ListBox(name="artist-list")
|
||||
self.list.bind_model(self.artists_store, create_artist_row)
|
||||
list_scroll_window.add(self.list)
|
||||
|
||||
@@ -124,24 +124,17 @@ class ArtistList(Gtk.Box):
|
||||
on_failure=lambda self, e: self.loading_indicator.hide(),
|
||||
)
|
||||
def update(
|
||||
self,
|
||||
artists: List[ArtistID3],
|
||||
app_config: AppConfiguration,
|
||||
**kwargs,
|
||||
self, artists: List[ArtistID3], app_config: AppConfiguration, **kwargs,
|
||||
):
|
||||
new_store = []
|
||||
selected_idx = None
|
||||
for i, artist in enumerate(artists):
|
||||
if (app_config.state
|
||||
and app_config.state.selected_artist_id == artist.id):
|
||||
if app_config.state and app_config.state.selected_artist_id == artist.id:
|
||||
selected_idx = i
|
||||
|
||||
new_store.append(
|
||||
_ArtistModel(
|
||||
artist.id,
|
||||
artist.name,
|
||||
artist.get('albumCount', ''),
|
||||
))
|
||||
_ArtistModel(artist.id, artist.name, artist.get("albumCount", ""),)
|
||||
)
|
||||
|
||||
util.diff_model_store(self.artists_store, new_store)
|
||||
|
||||
@@ -157,7 +150,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
"""Defines the artists list."""
|
||||
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
@@ -167,7 +160,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
update_order_token = 0
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, name='artist-detail-panel', **kwargs)
|
||||
super().__init__(*args, name="artist-detail-panel", **kwargs)
|
||||
self.albums: Union[List[AlbumID3], List[Child]] = []
|
||||
self.artist_id = None
|
||||
|
||||
@@ -178,8 +171,8 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
|
||||
self.artist_artwork = SpinnerImage(
|
||||
loading=False,
|
||||
image_name='artist-album-artwork',
|
||||
spinner_name='artist-artwork-spinner',
|
||||
image_name="artist-album-artwork",
|
||||
spinner_name="artist-artwork-spinner",
|
||||
image_size=300,
|
||||
)
|
||||
self.big_info_panel.pack_start(self.artist_artwork, False, False, 0)
|
||||
@@ -189,71 +182,66 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
|
||||
# Action buttons (note we are packing end here, so we have to put them
|
||||
# in right-to-left).
|
||||
self.artist_action_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.artist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
view_refresh_button = IconButton(
|
||||
'view-refresh-symbolic', 'Refresh artist info')
|
||||
view_refresh_button.connect('clicked', self.on_view_refresh_click)
|
||||
self.artist_action_buttons.pack_end(
|
||||
view_refresh_button, False, False, 5)
|
||||
view_refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
|
||||
view_refresh_button.connect("clicked", self.on_view_refresh_click)
|
||||
self.artist_action_buttons.pack_end(view_refresh_button, False, False, 5)
|
||||
|
||||
download_all_btn = IconButton(
|
||||
'folder-download-symbolic', 'Download all songs by this artist')
|
||||
download_all_btn.connect('clicked', self.on_download_all_click)
|
||||
"folder-download-symbolic", "Download all songs by this artist"
|
||||
)
|
||||
download_all_btn.connect("clicked", self.on_download_all_click)
|
||||
self.artist_action_buttons.pack_end(download_all_btn, False, False, 5)
|
||||
|
||||
artist_details_box.pack_start(
|
||||
self.artist_action_buttons, False, False, 5)
|
||||
artist_details_box.pack_start(self.artist_action_buttons, False, False, 5)
|
||||
|
||||
artist_details_box.pack_start(Gtk.Box(), True, False, 0)
|
||||
|
||||
self.artist_indicator = self.make_label(name='artist-indicator')
|
||||
self.artist_indicator = self.make_label(name="artist-indicator")
|
||||
artist_details_box.add(self.artist_indicator)
|
||||
|
||||
self.artist_name = self.make_label(name='artist-name')
|
||||
self.artist_name = self.make_label(name="artist-name")
|
||||
artist_details_box.add(self.artist_name)
|
||||
|
||||
self.artist_bio = self.make_label(
|
||||
name='artist-bio', justify=Gtk.Justification.LEFT)
|
||||
name="artist-bio", justify=Gtk.Justification.LEFT
|
||||
)
|
||||
self.artist_bio.set_line_wrap(True)
|
||||
artist_details_box.add(self.artist_bio)
|
||||
|
||||
self.similar_artists_scrolledwindow = Gtk.ScrolledWindow()
|
||||
similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.similar_artists_label = self.make_label(name='similar-artists')
|
||||
self.similar_artists_label = self.make_label(name="similar-artists")
|
||||
similar_artists_box.add(self.similar_artists_label)
|
||||
|
||||
self.similar_artists_button_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
orientation=Gtk.Orientation.HORIZONTAL
|
||||
)
|
||||
similar_artists_box.add(self.similar_artists_button_box)
|
||||
self.similar_artists_scrolledwindow.add(similar_artists_box)
|
||||
|
||||
artist_details_box.add(self.similar_artists_scrolledwindow)
|
||||
|
||||
self.artist_stats = self.make_label(name='artist-stats')
|
||||
self.artist_stats = self.make_label(name="artist-stats")
|
||||
artist_details_box.add(self.artist_stats)
|
||||
|
||||
self.play_shuffle_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
name='playlist-play-shuffle-buttons',
|
||||
name="playlist-play-shuffle-buttons",
|
||||
)
|
||||
|
||||
play_button = IconButton(
|
||||
'media-playback-start-symbolic',
|
||||
label='Play All',
|
||||
relief=True,
|
||||
"media-playback-start-symbolic", label="Play All", relief=True,
|
||||
)
|
||||
play_button.connect('clicked', self.on_play_all_clicked)
|
||||
play_button.connect("clicked", self.on_play_all_clicked)
|
||||
self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
|
||||
|
||||
shuffle_button = IconButton(
|
||||
'media-playlist-shuffle-symbolic',
|
||||
label='Shuffle All',
|
||||
relief=True,
|
||||
"media-playlist-shuffle-symbolic", label="Shuffle All", relief=True,
|
||||
)
|
||||
shuffle_button.connect('clicked', self.on_shuffle_all_button)
|
||||
shuffle_button.connect("clicked", self.on_shuffle_all_button)
|
||||
self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
|
||||
artist_details_box.add(self.play_shuffle_buttons)
|
||||
|
||||
@@ -263,8 +251,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
|
||||
self.albums_list = AlbumsListWithSongs()
|
||||
self.albums_list.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
artist_info_box.pack_start(self.albums_list, True, True, 0)
|
||||
|
||||
@@ -274,11 +261,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
self.artist_id = app_config.state.selected_artist_id
|
||||
if app_config.state.selected_artist_id is None:
|
||||
self.artist_action_buttons.hide()
|
||||
self.artist_indicator.set_text('')
|
||||
self.artist_name.set_markup('')
|
||||
self.artist_stats.set_markup('')
|
||||
self.artist_indicator.set_text("")
|
||||
self.artist_name.set_markup("")
|
||||
self.artist_stats.set_markup("")
|
||||
|
||||
self.artist_bio.set_markup('')
|
||||
self.artist_bio.set_markup("")
|
||||
self.similar_artists_scrolledwindow.hide()
|
||||
self.play_shuffle_buttons.hide()
|
||||
|
||||
@@ -310,27 +297,21 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
if order_token != self.update_order_token:
|
||||
return
|
||||
|
||||
self.artist_indicator.set_text('ARTIST')
|
||||
self.artist_name.set_markup(util.esc(f'<b>{artist.name}</b>'))
|
||||
self.artist_indicator.set_text("ARTIST")
|
||||
self.artist_name.set_markup(util.esc(f"<b>{artist.name}</b>"))
|
||||
self.artist_stats.set_markup(self.format_stats(artist))
|
||||
|
||||
self.update_artist_info(
|
||||
artist.id,
|
||||
force=force,
|
||||
order_token=order_token,
|
||||
artist.id, force=force, order_token=order_token,
|
||||
)
|
||||
self.update_artist_artwork(
|
||||
artist,
|
||||
force=force,
|
||||
order_token=order_token,
|
||||
artist, force=force, order_token=order_token,
|
||||
)
|
||||
|
||||
self.albums = artist.get('album', artist.get('child', []))
|
||||
self.albums = artist.get("album", artist.get("child", []))
|
||||
self.albums_list.update(artist)
|
||||
|
||||
@util.async_callback(
|
||||
lambda *a, **k: CacheManager.get_artist_info(*a, **k),
|
||||
)
|
||||
@util.async_callback(lambda *a, **k: CacheManager.get_artist_info(*a, **k),)
|
||||
def update_artist_info(
|
||||
self,
|
||||
artist_info: ArtistInfo2,
|
||||
@@ -341,11 +322,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
if order_token != self.update_order_token:
|
||||
return
|
||||
|
||||
self.artist_bio.set_markup(util.esc(''.join(artist_info.biography)))
|
||||
self.artist_bio.set_markup(util.esc("".join(artist_info.biography)))
|
||||
self.play_shuffle_buttons.show_all()
|
||||
|
||||
if len(artist_info.similarArtist or []) > 0:
|
||||
self.similar_artists_label.set_markup('<b>Similar Artists:</b> ')
|
||||
self.similar_artists_label.set_markup("<b>Similar Artists:</b> ")
|
||||
for c in self.similar_artists_button_box.get_children():
|
||||
self.similar_artists_button_box.remove(c)
|
||||
|
||||
@@ -353,10 +334,11 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
self.similar_artists_button_box.add(
|
||||
Gtk.LinkButton(
|
||||
label=artist.name,
|
||||
name='similar-artist-button',
|
||||
action_name='app.go-to-artist',
|
||||
action_target=GLib.Variant('s', artist.id),
|
||||
))
|
||||
name="similar-artist-button",
|
||||
action_name="app.go-to-artist",
|
||||
action_target=GLib.Variant("s", artist.id),
|
||||
)
|
||||
)
|
||||
self.similar_artists_scrolledwindow.show_all()
|
||||
else:
|
||||
self.similar_artists_scrolledwindow.hide()
|
||||
@@ -383,41 +365,33 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
# =========================================================================
|
||||
def on_view_refresh_click(self, *args):
|
||||
self.update_artist_view(
|
||||
self.artist_id,
|
||||
force=True,
|
||||
order_token=self.update_order_token,
|
||||
self.artist_id, force=True, order_token=self.update_order_token,
|
||||
)
|
||||
|
||||
def on_download_all_click(self, btn: Any):
|
||||
CacheManager.batch_download_songs(
|
||||
self.get_artist_song_ids(),
|
||||
before_download=lambda: self.update_artist_view(
|
||||
self.artist_id,
|
||||
order_token=self.update_order_token,
|
||||
self.artist_id, order_token=self.update_order_token,
|
||||
),
|
||||
on_song_download_complete=lambda i: self.update_artist_view(
|
||||
self.artist_id,
|
||||
order_token=self.update_order_token,
|
||||
self.artist_id, order_token=self.update_order_token,
|
||||
),
|
||||
)
|
||||
|
||||
def on_play_all_clicked(self, btn: Any):
|
||||
songs = self.get_artist_song_ids()
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
0,
|
||||
songs,
|
||||
{'force_shuffle_state': False},
|
||||
"song-clicked", 0, songs, {"force_shuffle_state": False},
|
||||
)
|
||||
|
||||
def on_shuffle_all_button(self, btn: Any):
|
||||
songs = self.get_artist_song_ids()
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
randint(0,
|
||||
len(songs) - 1),
|
||||
"song-clicked",
|
||||
randint(0, len(songs) - 1),
|
||||
songs,
|
||||
{'force_shuffle_state': True},
|
||||
{"force_shuffle_state": True},
|
||||
)
|
||||
|
||||
# Helper Methods
|
||||
@@ -431,27 +405,18 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
self.albums_list.spinner.hide()
|
||||
self.artist_artwork.set_loading(False)
|
||||
|
||||
def make_label(
|
||||
self,
|
||||
text: str = None,
|
||||
name: str = None,
|
||||
**params,
|
||||
) -> Gtk.Label:
|
||||
def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label:
|
||||
return Gtk.Label(
|
||||
label=text,
|
||||
name=name,
|
||||
halign=Gtk.Align.START,
|
||||
xalign=0,
|
||||
**params,
|
||||
label=text, name=name, halign=Gtk.Align.START, xalign=0, **params,
|
||||
)
|
||||
|
||||
def format_stats(self, artist: ArtistWithAlbumsID3) -> str:
|
||||
album_count = artist.get('albumCount', 0)
|
||||
album_count = artist.get("albumCount", 0)
|
||||
song_count = sum(a.songCount for a in artist.album)
|
||||
duration = sum(a.duration for a in artist.album)
|
||||
return util.dot_join(
|
||||
'{} {}'.format(album_count, util.pluralize('album', album_count)),
|
||||
'{} {}'.format(song_count, util.pluralize('song', song_count)),
|
||||
"{} {}".format(album_count, util.pluralize("album", album_count)),
|
||||
"{} {}".format(song_count, util.pluralize("song", song_count)),
|
||||
util.format_sequence_duration(duration),
|
||||
)
|
||||
|
||||
@@ -459,7 +424,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
songs = []
|
||||
for album in CacheManager.get_artist(self.artist_id).result().album:
|
||||
album_songs = CacheManager.get_album(album.id).result()
|
||||
for song in album_songs.get('song', []):
|
||||
for song in album_songs.get("song", []):
|
||||
songs.append(song.id)
|
||||
|
||||
return songs
|
||||
@@ -467,7 +432,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
|
||||
|
||||
class AlbumsListWithSongs(Gtk.Overlay):
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
@@ -480,7 +445,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
||||
self.add(self.box)
|
||||
|
||||
self.spinner = Gtk.Spinner(
|
||||
name='albumslist-with-songs-spinner',
|
||||
name="albumslist-with-songs-spinner",
|
||||
active=False,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
@@ -499,7 +464,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
||||
self.spinner.hide()
|
||||
return
|
||||
|
||||
new_albums = artist.get('album', artist.get('child', []))
|
||||
new_albums = artist.get("album", artist.get("child", []))
|
||||
|
||||
if self.albums == new_albums:
|
||||
# No need to do anything.
|
||||
@@ -513,10 +478,9 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
||||
for album in self.albums:
|
||||
album_with_songs = AlbumWithSongs(album, show_artist_name=False)
|
||||
album_with_songs.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
album_with_songs.connect('song-selected', self.on_song_selected)
|
||||
album_with_songs.connect("song-selected", self.on_song_selected)
|
||||
album_with_songs.show_all()
|
||||
self.box.add(album_with_songs)
|
||||
|
||||
|
@@ -1,7 +1,8 @@
|
||||
from typing import Any, List, Optional, Tuple, Type, Union
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
|
||||
|
||||
from sublime.cache_manager import CacheManager
|
||||
@@ -15,12 +16,12 @@ class BrowsePanel(Gtk.Overlay):
|
||||
"""Defines the arist panel."""
|
||||
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -36,19 +37,17 @@ class BrowsePanel(Gtk.Overlay):
|
||||
|
||||
self.root_directory_listing = ListAndDrilldown(IndexList)
|
||||
self.root_directory_listing.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
self.root_directory_listing.connect(
|
||||
'refresh-window',
|
||||
lambda _, *args: self.emit('refresh-window', *args),
|
||||
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
|
||||
)
|
||||
scrolled_window.add(self.root_directory_listing)
|
||||
|
||||
self.add(scrolled_window)
|
||||
|
||||
self.spinner = Gtk.Spinner(
|
||||
name='browse-spinner',
|
||||
name="browse-spinner",
|
||||
active=True,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
@@ -66,9 +65,7 @@ class BrowsePanel(Gtk.Overlay):
|
||||
return
|
||||
|
||||
self.root_directory_listing.update(
|
||||
id_stack,
|
||||
app_config=app_config,
|
||||
force=force,
|
||||
id_stack, app_config=app_config, force=force,
|
||||
)
|
||||
self.spinner.hide()
|
||||
|
||||
@@ -81,30 +78,25 @@ class BrowsePanel(Gtk.Overlay):
|
||||
current_dir_id = app_config.state.selected_browse_element_id
|
||||
while directory is None or directory.parent is not None:
|
||||
directory = CacheManager.get_music_directory(
|
||||
current_dir_id,
|
||||
before_download=self.spinner.show,
|
||||
current_dir_id, before_download=self.spinner.show,
|
||||
).result()
|
||||
id_stack.append(directory.id)
|
||||
current_dir_id = directory.parent
|
||||
|
||||
return id_stack, update_order_token
|
||||
|
||||
path_fut = CacheManager.create_future(
|
||||
calculate_path,
|
||||
self.update_order_token,
|
||||
)
|
||||
path_fut.add_done_callback(
|
||||
lambda f: GLib.idle_add(do_update, *f.result()))
|
||||
path_fut = CacheManager.create_future(calculate_path, self.update_order_token,)
|
||||
path_fut.add_done_callback(lambda f: GLib.idle_add(do_update, *f.result()))
|
||||
|
||||
|
||||
class ListAndDrilldown(Gtk.Paned):
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -118,12 +110,10 @@ class ListAndDrilldown(Gtk.Paned):
|
||||
|
||||
self.list = list_type()
|
||||
self.list.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
self.list.connect(
|
||||
'refresh-window',
|
||||
lambda _, *args: self.emit('refresh-window', *args),
|
||||
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
|
||||
)
|
||||
self.pack1(self.list, False, False)
|
||||
|
||||
@@ -149,10 +139,7 @@ class ListAndDrilldown(Gtk.Paned):
|
||||
# away the drilldown.
|
||||
if isinstance(self.drilldown, ListAndDrilldown):
|
||||
self.drilldown.update(
|
||||
id_stack[:-1],
|
||||
app_config,
|
||||
force=force,
|
||||
directory_id=id_stack[-1],
|
||||
id_stack[:-1], app_config, force=force, directory_id=id_stack[-1],
|
||||
)
|
||||
return
|
||||
self.id_stack = id_stack
|
||||
@@ -161,18 +148,13 @@ class ListAndDrilldown(Gtk.Paned):
|
||||
self.remove(self.drilldown)
|
||||
self.drilldown = ListAndDrilldown(MusicDirectoryList)
|
||||
self.drilldown.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
self.drilldown.connect(
|
||||
'refresh-window',
|
||||
lambda _, *args: self.emit('refresh-window', *args),
|
||||
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
|
||||
)
|
||||
self.drilldown.update(
|
||||
id_stack[:-1],
|
||||
app_config,
|
||||
force=force,
|
||||
directory_id=id_stack[-1],
|
||||
id_stack[:-1], app_config, force=force, directory_id=id_stack[-1],
|
||||
)
|
||||
self.drilldown.show_all()
|
||||
self.pack2(self.drilldown, True, False)
|
||||
@@ -180,12 +162,12 @@ class ListAndDrilldown(Gtk.Paned):
|
||||
|
||||
class DrilldownList(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -200,24 +182,23 @@ class DrilldownList(Gtk.Box):
|
||||
def __init__(self, element: Union[Child, Artist]):
|
||||
GObject.GObject.__init__(self)
|
||||
self.id = element.id
|
||||
self.name = (
|
||||
element.name if isinstance(element, Artist) else element.title)
|
||||
self.is_dir = element.get('isDir', True)
|
||||
self.name = element.name if isinstance(element, Artist) else element.title
|
||||
self.is_dir = element.get("isDir", True)
|
||||
|
||||
def __init__(self):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
list_actions = Gtk.ActionBar()
|
||||
|
||||
refresh = IconButton('view-refresh-symbolic', 'Refresh folder')
|
||||
refresh.connect('clicked', self.on_refresh_clicked)
|
||||
refresh = IconButton("view-refresh-symbolic", "Refresh folder")
|
||||
refresh.connect("clicked", self.on_refresh_clicked)
|
||||
list_actions.pack_end(refresh)
|
||||
|
||||
self.add(list_actions)
|
||||
|
||||
self.loading_indicator = Gtk.ListBox()
|
||||
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False)
|
||||
spinner = Gtk.Spinner(name='drilldown-list-spinner', active=True)
|
||||
spinner = Gtk.Spinner(name="drilldown-list-spinner", active=True)
|
||||
spinner_row.add(spinner)
|
||||
self.loading_indicator.add(spinner_row)
|
||||
self.pack_start(self.loading_indicator, False, False, 0)
|
||||
@@ -231,36 +212,32 @@ class DrilldownList(Gtk.Box):
|
||||
scrollbox.add(self.list)
|
||||
|
||||
self.directory_song_store = Gtk.ListStore(
|
||||
str, # cache status
|
||||
str, # title
|
||||
str, # duration
|
||||
str, # song ID
|
||||
str, str, str, str, # cache status # title # duration # song ID
|
||||
)
|
||||
|
||||
self.directory_song_list = Gtk.TreeView(
|
||||
model=self.directory_song_store,
|
||||
name='album-songs-list',
|
||||
name="album-songs-list",
|
||||
headers_visible=False,
|
||||
)
|
||||
self.directory_song_list.get_selection().set_mode(
|
||||
Gtk.SelectionMode.MULTIPLE)
|
||||
self.directory_song_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||
|
||||
# Song status column.
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(30, 35)
|
||||
column = Gtk.TreeViewColumn('', renderer, icon_name=0)
|
||||
column = Gtk.TreeViewColumn("", renderer, icon_name=0)
|
||||
column.set_resizable(True)
|
||||
self.directory_song_list.append_column(column)
|
||||
|
||||
self.directory_song_list.append_column(SongListColumn("TITLE", 1, bold=True))
|
||||
self.directory_song_list.append_column(
|
||||
SongListColumn('TITLE', 1, bold=True))
|
||||
self.directory_song_list.append_column(
|
||||
SongListColumn('DURATION', 2, align=1, width=40))
|
||||
SongListColumn("DURATION", 2, align=1, width=40)
|
||||
)
|
||||
|
||||
self.directory_song_list.connect("row-activated", self.on_song_activated)
|
||||
self.directory_song_list.connect(
|
||||
'row-activated', self.on_song_activated)
|
||||
self.directory_song_list.connect(
|
||||
'button-press-event', self.on_song_button_press)
|
||||
"button-press-event", self.on_song_button_press
|
||||
)
|
||||
scrollbox.add(self.directory_song_list)
|
||||
|
||||
self.scroll_window.add(scrollbox)
|
||||
@@ -269,17 +246,13 @@ class DrilldownList(Gtk.Box):
|
||||
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
|
||||
# The song ID is in the last column of the model.
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
"song-clicked",
|
||||
idx.get_indices()[0],
|
||||
[m[-1] for m in self.directory_song_store],
|
||||
{},
|
||||
)
|
||||
|
||||
def on_song_button_press(
|
||||
self,
|
||||
tree: Gtk.TreeView,
|
||||
event: Gdk.EventButton,
|
||||
) -> bool:
|
||||
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton,) -> bool:
|
||||
if event.button == 3: # Right click
|
||||
clicked_path = tree.get_path_at_pos(event.x, event.y)
|
||||
if not clicked_path:
|
||||
@@ -297,10 +270,8 @@ class DrilldownList(Gtk.Box):
|
||||
song_ids = [self.directory_song_store[p][-1] for p in paths]
|
||||
|
||||
# Used to adjust for the header row.
|
||||
bin_coords = tree.convert_tree_to_bin_window_coords(
|
||||
event.x, event.y)
|
||||
widget_coords = tree.convert_tree_to_widget_coords(
|
||||
event.x, event.y)
|
||||
bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
|
||||
widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
|
||||
|
||||
util.show_song_popover(
|
||||
song_ids,
|
||||
@@ -322,23 +293,21 @@ class DrilldownList(Gtk.Box):
|
||||
selected_dir_idx = None
|
||||
|
||||
for idx, el in enumerate(elements or []):
|
||||
if el.get('isDir', True):
|
||||
new_directories_store.append(
|
||||
DrilldownList.DrilldownElement(el))
|
||||
if el.get("isDir", True):
|
||||
new_directories_store.append(DrilldownList.DrilldownElement(el))
|
||||
if el.id == self.selected_id:
|
||||
selected_dir_idx = idx
|
||||
else:
|
||||
new_songs_store.append(
|
||||
[
|
||||
util.get_cached_status_icon(
|
||||
CacheManager.get_cached_status(el)),
|
||||
util.get_cached_status_icon(CacheManager.get_cached_status(el)),
|
||||
util.esc(el.title),
|
||||
util.format_song_duration(el.duration),
|
||||
el.id,
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
util.diff_model_store(
|
||||
self.drilldown_directories_store, new_directories_store)
|
||||
util.diff_model_store(self.drilldown_directories_store, new_directories_store)
|
||||
|
||||
util.diff_song_store(self.directory_song_store, new_songs_store)
|
||||
|
||||
@@ -361,23 +330,22 @@ class DrilldownList(Gtk.Box):
|
||||
|
||||
self.loading_indicator.hide()
|
||||
|
||||
def create_row(
|
||||
self, model: 'DrilldownList.DrilldownElement') -> Gtk.ListBoxRow:
|
||||
def create_row(self, model: "DrilldownList.DrilldownElement") -> Gtk.ListBoxRow:
|
||||
row = Gtk.ListBoxRow(
|
||||
action_name='app.browse-to',
|
||||
action_target=GLib.Variant('s', model.id),
|
||||
action_name="app.browse-to", action_target=GLib.Variant("s", model.id),
|
||||
)
|
||||
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
rowbox.add(
|
||||
Gtk.Label(
|
||||
label=f'<b>{util.esc(model.name)}</b>',
|
||||
label=f"<b>{util.esc(model.name)}</b>",
|
||||
use_markup=True,
|
||||
margin=8,
|
||||
halign=Gtk.Align.START,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
icon = Gio.ThemedIcon(name='go-next-symbolic')
|
||||
icon = Gio.ThemedIcon(name="go-next-symbolic")
|
||||
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
|
||||
rowbox.pack_end(image, False, False, 5)
|
||||
row.add(rowbox)
|
||||
@@ -398,9 +366,7 @@ class IndexList(DrilldownList):
|
||||
self.update_order_token += 1
|
||||
self.selected_id = selected_id
|
||||
self.update_store(
|
||||
force=force,
|
||||
app_config=app_config,
|
||||
order_token=self.update_order_token,
|
||||
force=force, app_config=app_config, order_token=self.update_order_token,
|
||||
)
|
||||
|
||||
def on_refresh_clicked(self, _: Any):
|
||||
@@ -447,8 +413,7 @@ class MusicDirectoryList(DrilldownList):
|
||||
)
|
||||
|
||||
def on_refresh_clicked(self, _: Any):
|
||||
self.update(
|
||||
self.selected_id, force=True, directory_id=self.directory_id)
|
||||
self.update(self.selected_id, force=True, directory_id=self.directory_id)
|
||||
|
||||
@util.async_callback(
|
||||
lambda *a, **k: CacheManager.get_music_directory(*a, **k),
|
||||
|
@@ -5,10 +5,10 @@ from .song_list_column import SongListColumn
|
||||
from .spinner_image import SpinnerImage
|
||||
|
||||
__all__ = (
|
||||
'AlbumWithSongs',
|
||||
'EditFormDialog',
|
||||
'IconButton',
|
||||
'IconToggleButton',
|
||||
'SongListColumn',
|
||||
'SpinnerImage',
|
||||
"AlbumWithSongs",
|
||||
"EditFormDialog",
|
||||
"IconButton",
|
||||
"IconToggleButton",
|
||||
"SongListColumn",
|
||||
"SpinnerImage",
|
||||
)
|
||||
|
@@ -2,7 +2,8 @@ from random import randint
|
||||
from typing import Any, Union
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GLib, GObject, Gtk, Pango
|
||||
|
||||
from sublime.cache_manager import CacheManager
|
||||
@@ -16,12 +17,8 @@ from sublime.ui.common.spinner_image import SpinnerImage
|
||||
|
||||
class AlbumWithSongs(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
'song-selected': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(),
|
||||
),
|
||||
'song-clicked': (
|
||||
"song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (),),
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
@@ -40,13 +37,12 @@ class AlbumWithSongs(Gtk.Box):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
artist_artwork = SpinnerImage(
|
||||
loading=False,
|
||||
image_name='artist-album-list-artwork',
|
||||
spinner_name='artist-artwork-spinner',
|
||||
image_name="artist-album-list-artwork",
|
||||
spinner_name="artist-artwork-spinner",
|
||||
image_size=cover_art_size,
|
||||
)
|
||||
# Account for 10px margin on all sides with "+ 20".
|
||||
artist_artwork.set_size_request(
|
||||
cover_art_size + 20, cover_art_size + 20)
|
||||
artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
|
||||
box.pack_start(artist_artwork, False, False, 0)
|
||||
box.pack_start(Gtk.Box(), True, True, 0)
|
||||
self.pack_start(box, False, False, 0)
|
||||
@@ -56,64 +52,62 @@ class AlbumWithSongs(Gtk.Box):
|
||||
artist_artwork.set_loading(False)
|
||||
|
||||
cover_art_filename_future = CacheManager.get_cover_art_filename(
|
||||
album.coverArt,
|
||||
before_download=lambda: artist_artwork.set_loading(True),
|
||||
album.coverArt, before_download=lambda: artist_artwork.set_loading(True),
|
||||
)
|
||||
cover_art_filename_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(cover_art_future_done, f))
|
||||
lambda f: GLib.idle_add(cover_art_future_done, f)
|
||||
)
|
||||
|
||||
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
album_title_and_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# TODO (#43): deal with super long-ass titles
|
||||
album_title_and_buttons.add(
|
||||
Gtk.Label(
|
||||
label=album.get('name', album.get('title')),
|
||||
name='artist-album-list-album-name',
|
||||
label=album.get("name", album.get("title")),
|
||||
name="artist-album-list-album-name",
|
||||
halign=Gtk.Align.START,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
self.play_btn = IconButton(
|
||||
'media-playback-start-symbolic',
|
||||
'Play all songs in this album',
|
||||
"media-playback-start-symbolic",
|
||||
"Play all songs in this album",
|
||||
sensitive=False,
|
||||
)
|
||||
self.play_btn.connect('clicked', self.play_btn_clicked)
|
||||
self.play_btn.connect("clicked", self.play_btn_clicked)
|
||||
album_title_and_buttons.pack_start(self.play_btn, False, False, 5)
|
||||
|
||||
self.shuffle_btn = IconButton(
|
||||
'media-playlist-shuffle-symbolic',
|
||||
'Shuffle all songs in this album',
|
||||
"media-playlist-shuffle-symbolic",
|
||||
"Shuffle all songs in this album",
|
||||
sensitive=False,
|
||||
)
|
||||
self.shuffle_btn.connect('clicked', self.shuffle_btn_clicked)
|
||||
self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked)
|
||||
album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
|
||||
|
||||
self.play_next_btn = IconButton(
|
||||
'go-top-symbolic',
|
||||
'Play all of the songs in this album next',
|
||||
action_name='app.play-next',
|
||||
"go-top-symbolic",
|
||||
"Play all of the songs in this album next",
|
||||
action_name="app.play-next",
|
||||
)
|
||||
album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
|
||||
|
||||
self.add_to_queue_btn = IconButton(
|
||||
'go-jump-symbolic',
|
||||
'Add all the songs in this album to the end of the play queue',
|
||||
action_name='app.add-to-queue',
|
||||
"go-jump-symbolic",
|
||||
"Add all the songs in this album to the end of the play queue",
|
||||
action_name="app.add-to-queue",
|
||||
)
|
||||
album_title_and_buttons.pack_start(
|
||||
self.add_to_queue_btn, False, False, 5)
|
||||
album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False, 5)
|
||||
|
||||
self.download_all_btn = IconButton(
|
||||
'folder-download-symbolic',
|
||||
'Download all songs in this album',
|
||||
"folder-download-symbolic",
|
||||
"Download all songs in this album",
|
||||
sensitive=False,
|
||||
)
|
||||
self.download_all_btn.connect('clicked', self.on_download_all_click)
|
||||
album_title_and_buttons.pack_end(
|
||||
self.download_all_btn, False, False, 5)
|
||||
self.download_all_btn.connect("clicked", self.on_download_all_click)
|
||||
album_title_and_buttons.pack_end(self.download_all_btn, False, False, 5)
|
||||
|
||||
album_details.add(album_title_and_buttons)
|
||||
|
||||
@@ -122,30 +116,26 @@ class AlbumWithSongs(Gtk.Box):
|
||||
album.year,
|
||||
album.genre,
|
||||
util.format_sequence_duration(album.duration)
|
||||
if album.get('duration') else None,
|
||||
if album.get("duration")
|
||||
else None,
|
||||
]
|
||||
|
||||
album_details.add(
|
||||
Gtk.Label(
|
||||
label=util.dot_join(*stats),
|
||||
halign=Gtk.Align.START,
|
||||
margin_left=10,
|
||||
))
|
||||
|
||||
self.album_song_store = Gtk.ListStore(
|
||||
str, # cache status
|
||||
str, # title
|
||||
str, # duration
|
||||
str, # song ID
|
||||
label=util.dot_join(*stats), halign=Gtk.Align.START, margin_left=10,
|
||||
)
|
||||
)
|
||||
|
||||
self.loading_indicator = Gtk.Spinner(
|
||||
name='album-list-song-list-spinner')
|
||||
self.album_song_store = Gtk.ListStore(
|
||||
str, str, str, str, # cache status # title # duration # song ID
|
||||
)
|
||||
|
||||
self.loading_indicator = Gtk.Spinner(name="album-list-song-list-spinner")
|
||||
album_details.add(self.loading_indicator)
|
||||
|
||||
self.album_songs = Gtk.TreeView(
|
||||
model=self.album_song_store,
|
||||
name='album-songs-list',
|
||||
name="album-songs-list",
|
||||
headers_visible=False,
|
||||
margin_top=15,
|
||||
margin_left=10,
|
||||
@@ -157,19 +147,18 @@ class AlbumWithSongs(Gtk.Box):
|
||||
# Song status column.
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(30, 35)
|
||||
column = Gtk.TreeViewColumn('', renderer, icon_name=0)
|
||||
column = Gtk.TreeViewColumn("", renderer, icon_name=0)
|
||||
column.set_resizable(True)
|
||||
self.album_songs.append_column(column)
|
||||
|
||||
self.album_songs.append_column(SongListColumn('TITLE', 1, bold=True))
|
||||
self.album_songs.append_column(
|
||||
SongListColumn('DURATION', 2, align=1, width=40))
|
||||
self.album_songs.append_column(SongListColumn("TITLE", 1, bold=True))
|
||||
self.album_songs.append_column(SongListColumn("DURATION", 2, align=1, width=40))
|
||||
|
||||
self.album_songs.connect('row-activated', self.on_song_activated)
|
||||
self.album_songs.connect(
|
||||
'button-press-event', self.on_song_button_press)
|
||||
self.album_songs.connect("row-activated", self.on_song_activated)
|
||||
self.album_songs.connect("button-press-event", self.on_song_button_press)
|
||||
self.album_songs.get_selection().connect(
|
||||
'changed', self.on_song_selection_change)
|
||||
"changed", self.on_song_selection_change
|
||||
)
|
||||
album_details.add(self.album_songs)
|
||||
|
||||
self.pack_end(album_details, True, True, 0)
|
||||
@@ -180,12 +169,12 @@ class AlbumWithSongs(Gtk.Box):
|
||||
# =========================================================================
|
||||
def on_song_selection_change(self, event: Any):
|
||||
if not self.album_songs.has_focus():
|
||||
self.emit('song-selected')
|
||||
self.emit("song-selected")
|
||||
|
||||
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
|
||||
# The song ID is in the last column of the model.
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
"song-clicked",
|
||||
idx.get_indices()[0],
|
||||
[m[-1] for m in self.album_song_store],
|
||||
{},
|
||||
@@ -212,10 +201,8 @@ class AlbumWithSongs(Gtk.Box):
|
||||
song_ids = [self.album_song_store[p][-1] for p in paths]
|
||||
|
||||
# Used to adjust for the header row.
|
||||
bin_coords = tree.convert_tree_to_bin_window_coords(
|
||||
event.x, event.y)
|
||||
widget_coords = tree.convert_tree_to_widget_coords(
|
||||
event.x, event.y)
|
||||
bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
|
||||
widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
|
||||
|
||||
util.show_song_popover(
|
||||
song_ids,
|
||||
@@ -241,20 +228,16 @@ class AlbumWithSongs(Gtk.Box):
|
||||
def play_btn_clicked(self, btn: Any):
|
||||
song_ids = [x[-1] for x in self.album_song_store]
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
0,
|
||||
song_ids,
|
||||
{'force_shuffle_state': False},
|
||||
"song-clicked", 0, song_ids, {"force_shuffle_state": False},
|
||||
)
|
||||
|
||||
def shuffle_btn_clicked(self, btn: Any):
|
||||
song_ids = [x[-1] for x in self.album_song_store]
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
randint(0,
|
||||
len(self.album_song_store) - 1),
|
||||
"song-clicked",
|
||||
randint(0, len(self.album_song_store) - 1),
|
||||
song_ids,
|
||||
{'force_shuffle_state': True},
|
||||
{"force_shuffle_state": True},
|
||||
)
|
||||
|
||||
# Helper Methods
|
||||
@@ -287,22 +270,20 @@ class AlbumWithSongs(Gtk.Box):
|
||||
):
|
||||
new_store = [
|
||||
[
|
||||
util.get_cached_status_icon(
|
||||
CacheManager.get_cached_status(song)),
|
||||
util.get_cached_status_icon(CacheManager.get_cached_status(song)),
|
||||
util.esc(song.title),
|
||||
util.format_song_duration(song.duration),
|
||||
song.id,
|
||||
] for song in (album.get('child') or album.get('song') or [])
|
||||
]
|
||||
for song in (album.get("child") or album.get("song") or [])
|
||||
]
|
||||
|
||||
song_ids = [song[-1] for song in new_store]
|
||||
|
||||
self.play_btn.set_sensitive(True)
|
||||
self.shuffle_btn.set_sensitive(True)
|
||||
self.play_next_btn.set_action_target_value(
|
||||
GLib.Variant('as', song_ids))
|
||||
self.add_to_queue_btn.set_action_target_value(
|
||||
GLib.Variant('as', song_ids))
|
||||
self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
|
||||
self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
|
||||
self.download_all_btn.set_sensitive(True)
|
||||
|
||||
util.diff_song_store(self.album_song_store, new_store)
|
||||
|
@@ -1,7 +1,8 @@
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
TextFieldDescription = Tuple[str, str, bool]
|
||||
@@ -25,25 +26,22 @@ class EditFormDialog(Gtk.Dialog):
|
||||
"""
|
||||
Gets the friendly object name. Can be overridden.
|
||||
"""
|
||||
return obj.name if obj else ''
|
||||
return obj.name if obj else ""
|
||||
|
||||
def get_default_object(self):
|
||||
return None
|
||||
|
||||
def __init__(self, parent: Any, existing_object: Any = None):
|
||||
editing = existing_object is not None
|
||||
title = getattr(self, 'title', None)
|
||||
title = getattr(self, "title", None)
|
||||
if not title:
|
||||
if editing:
|
||||
title = f'Edit {self.get_object_name(existing_object)}'
|
||||
title = f"Edit {self.get_object_name(existing_object)}"
|
||||
else:
|
||||
title = f'Create New {self.entity_name}'
|
||||
title = f"Create New {self.entity_name}"
|
||||
|
||||
Gtk.Dialog.__init__(
|
||||
self,
|
||||
title=title,
|
||||
transient_for=parent,
|
||||
flags=0,
|
||||
self, title=title, transient_for=parent, flags=0,
|
||||
)
|
||||
if not existing_object:
|
||||
existing_object = self.get_default_object()
|
||||
@@ -55,22 +53,18 @@ class EditFormDialog(Gtk.Dialog):
|
||||
|
||||
content_area = self.get_content_area()
|
||||
content_grid = Gtk.Grid(
|
||||
column_spacing=10,
|
||||
row_spacing=5,
|
||||
margin_left=10,
|
||||
margin_right=10,
|
||||
column_spacing=10, row_spacing=5, margin_left=10, margin_right=10,
|
||||
)
|
||||
|
||||
# Add the text entries to the content area.
|
||||
i = 0
|
||||
for label, value_field_name, is_password in self.text_fields:
|
||||
entry_label = Gtk.Label(label=label + ':')
|
||||
entry_label = Gtk.Label(label=label + ":")
|
||||
entry_label.set_halign(Gtk.Align.START)
|
||||
content_grid.attach(entry_label, 0, i, 1, 1)
|
||||
|
||||
entry = Gtk.Entry(
|
||||
text=getattr(existing_object, value_field_name, ''),
|
||||
hexpand=True,
|
||||
text=getattr(existing_object, value_field_name, ""), hexpand=True,
|
||||
)
|
||||
if is_password:
|
||||
entry.set_visibility(False)
|
||||
@@ -80,7 +74,7 @@ class EditFormDialog(Gtk.Dialog):
|
||||
i += 1
|
||||
|
||||
for label, value_field_name, options in self.option_fields:
|
||||
entry_label = Gtk.Label(label=label + ':')
|
||||
entry_label = Gtk.Label(label=label + ":")
|
||||
entry_label.set_halign(Gtk.Align.START)
|
||||
content_grid.attach(entry_label, 0, i, 1, 1)
|
||||
|
||||
@@ -105,22 +99,27 @@ class EditFormDialog(Gtk.Dialog):
|
||||
|
||||
# Add the boolean entries to the content area.
|
||||
for label, value_field_name in self.boolean_fields:
|
||||
entry_label = Gtk.Label(label=label + ':')
|
||||
entry_label = Gtk.Label(label=label + ":")
|
||||
entry_label.set_halign(Gtk.Align.START)
|
||||
content_grid.attach(entry_label, 0, i, 1, 1)
|
||||
|
||||
# Put the checkbox in the right box. Note we have to pad here
|
||||
# since the checkboxes are smaller than the text fields.
|
||||
checkbox = Gtk.CheckButton(
|
||||
active=getattr(existing_object, value_field_name, False))
|
||||
active=getattr(existing_object, value_field_name, False)
|
||||
)
|
||||
self.data[value_field_name] = checkbox
|
||||
content_grid.attach(checkbox, 1, i, 1, 1)
|
||||
i += 1
|
||||
|
||||
# Add the spin button entries to the content area.
|
||||
for (label, value_field_name, range_config,
|
||||
default_value) in self.numeric_fields:
|
||||
entry_label = Gtk.Label(label=label + ':')
|
||||
for (
|
||||
label,
|
||||
value_field_name,
|
||||
range_config,
|
||||
default_value,
|
||||
) in self.numeric_fields:
|
||||
entry_label = Gtk.Label(label=label + ":")
|
||||
entry_label.set_halign(Gtk.Align.START)
|
||||
content_grid.attach(entry_label, 0, i, 1, 1)
|
||||
|
||||
@@ -128,7 +127,8 @@ class EditFormDialog(Gtk.Dialog):
|
||||
# since the checkboxes are smaller than the text fields.
|
||||
spin_button = Gtk.SpinButton.new_with_range(*range_config)
|
||||
spin_button.set_value(
|
||||
getattr(existing_object, value_field_name, default_value))
|
||||
getattr(existing_object, value_field_name, default_value)
|
||||
)
|
||||
self.data[value_field_name] = spin_button
|
||||
content_grid.attach(spin_button, 1, i, 1, 1)
|
||||
i += 1
|
||||
|
@@ -1,7 +1,8 @@
|
||||
from typing import Optional
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
@@ -9,7 +10,7 @@ class IconButton(Gtk.Button):
|
||||
def __init__(
|
||||
self,
|
||||
icon_name: Optional[str],
|
||||
tooltip_text: str = '',
|
||||
tooltip_text: str = "",
|
||||
relief: bool = False,
|
||||
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
|
||||
label: str = None,
|
||||
@@ -18,8 +19,7 @@ class IconButton(Gtk.Button):
|
||||
Gtk.Button.__init__(self, **kwargs)
|
||||
|
||||
self.icon_size = icon_size
|
||||
box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, name='icon-button-box')
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
|
||||
|
||||
self.image = Gtk.Image()
|
||||
self.image.set_from_icon_name(icon_name, self.icon_size)
|
||||
@@ -42,7 +42,7 @@ class IconToggleButton(Gtk.ToggleButton):
|
||||
def __init__(
|
||||
self,
|
||||
icon_name: Optional[str],
|
||||
tooltip_text: str = '',
|
||||
tooltip_text: str = "",
|
||||
relief: bool = False,
|
||||
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
|
||||
label: str = None,
|
||||
@@ -50,8 +50,7 @@ class IconToggleButton(Gtk.ToggleButton):
|
||||
):
|
||||
Gtk.ToggleButton.__init__(self, **kwargs)
|
||||
self.icon_size = icon_size
|
||||
box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, name='icon-button-box')
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
|
||||
|
||||
self.image = Gtk.Image()
|
||||
self.image.set_from_icon_name(icon_name, self.icon_size)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Pango
|
||||
|
||||
|
||||
|
@@ -1,7 +1,8 @@
|
||||
from typing import Optional
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GdkPixbuf, Gtk
|
||||
|
||||
|
||||
@@ -29,14 +30,11 @@ class SpinnerImage(Gtk.Overlay):
|
||||
self.add_overlay(self.spinner)
|
||||
|
||||
def set_from_file(self, filename: Optional[str]):
|
||||
if filename == '':
|
||||
if filename == "":
|
||||
filename = None
|
||||
if self.image_size is not None and filename:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
filename,
|
||||
self.image_size,
|
||||
self.image_size,
|
||||
True,
|
||||
filename, self.image_size, self.image_size, True,
|
||||
)
|
||||
self.image.set_from_pixbuf(pixbuf)
|
||||
else:
|
||||
|
@@ -2,7 +2,8 @@ import subprocess
|
||||
from typing import Any
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GObject, Gtk
|
||||
|
||||
from sublime.config import AppConfiguration, ServerConfiguration
|
||||
@@ -11,27 +12,27 @@ from sublime.ui.common import EditFormDialog, IconButton
|
||||
|
||||
|
||||
class EditServerDialog(EditFormDialog):
|
||||
entity_name: str = 'Server'
|
||||
entity_name: str = "Server"
|
||||
initial_size = (450, 250)
|
||||
text_fields = [
|
||||
('Name', 'name', False),
|
||||
('Server address', 'server_address', False),
|
||||
('Local network address', 'local_network_address', False),
|
||||
('Local network SSID', 'local_network_ssid', False),
|
||||
('Username', 'username', False),
|
||||
('Password', 'password', True),
|
||||
("Name", "name", False),
|
||||
("Server address", "server_address", False),
|
||||
("Local network address", "local_network_address", False),
|
||||
("Local network SSID", "local_network_ssid", False),
|
||||
("Username", "username", False),
|
||||
("Password", "password", True),
|
||||
]
|
||||
boolean_fields = [
|
||||
('Play queue sync enabled', 'sync_enabled'),
|
||||
('Do not verify certificate', 'disable_cert_verify'),
|
||||
("Play queue sync enabled", "sync_enabled"),
|
||||
("Do not verify certificate", "disable_cert_verify"),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
test_server = Gtk.Button(label='Test Connection to Server')
|
||||
test_server.connect('clicked', self.on_test_server_clicked)
|
||||
test_server = Gtk.Button(label="Test Connection to Server")
|
||||
test_server.connect("clicked", self.on_test_server_clicked)
|
||||
|
||||
open_in_browser = Gtk.Button(label='Open in Browser')
|
||||
open_in_browser.connect('clicked', self.on_open_in_browser_clicked)
|
||||
open_in_browser = Gtk.Button(label="Open in Browser")
|
||||
open_in_browser.connect("clicked", self.on_open_in_browser_clicked)
|
||||
|
||||
self.extra_buttons = [(test_server, None), (open_in_browser, None)]
|
||||
|
||||
@@ -39,13 +40,13 @@ class EditServerDialog(EditFormDialog):
|
||||
|
||||
def on_test_server_clicked(self, event: Any):
|
||||
# Instantiate the server.
|
||||
server_address = self.data['server_address'].get_text()
|
||||
server_address = self.data["server_address"].get_text()
|
||||
server = Server(
|
||||
name=self.data['name'].get_text(),
|
||||
name=self.data["name"].get_text(),
|
||||
hostname=server_address,
|
||||
username=self.data['username'].get_text(),
|
||||
password=self.data['password'].get_text(),
|
||||
disable_cert_verify=self.data['disable_cert_verify'].get_active(),
|
||||
username=self.data["username"].get_text(),
|
||||
password=self.data["password"].get_text(),
|
||||
disable_cert_verify=self.data["disable_cert_verify"].get_active(),
|
||||
)
|
||||
|
||||
# Try to ping, and show a message box with whether or not it worked.
|
||||
@@ -55,40 +56,48 @@ class EditServerDialog(EditFormDialog):
|
||||
transient_for=self,
|
||||
message_type=Gtk.MessageType.INFO,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text='Connection to server successful.',
|
||||
text="Connection to server successful.",
|
||||
)
|
||||
dialog.format_secondary_markup(
|
||||
f'Connection to {server_address} successful.')
|
||||
f"Connection to {server_address} successful."
|
||||
)
|
||||
except Exception as err:
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text='Connection to server unsuccessful.',
|
||||
text="Connection to server unsuccessful.",
|
||||
)
|
||||
dialog.format_secondary_markup(
|
||||
f'Connection to {server_address} resulted in the following '
|
||||
f'error:\n\n{err}')
|
||||
f"Connection to {server_address} resulted in the following "
|
||||
f"error:\n\n{err}"
|
||||
)
|
||||
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def on_open_in_browser_clicked(self, event: Any):
|
||||
subprocess.call(['xdg-open', self.data['server_address'].get_text()])
|
||||
subprocess.call(["xdg-open", self.data["server_address"].get_text()])
|
||||
|
||||
|
||||
class ConfigureServersDialog(Gtk.Dialog):
|
||||
__gsignals__ = {
|
||||
'server-list-changed':
|
||||
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )),
|
||||
'connected-server-changed':
|
||||
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )),
|
||||
"server-list-changed": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object,),
|
||||
),
|
||||
"connected-server-changed": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object,),
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, parent: Any, config: AppConfiguration):
|
||||
Gtk.Dialog.__init__(
|
||||
self,
|
||||
title='Configure Servers',
|
||||
title="Configure Servers",
|
||||
transient_for=parent,
|
||||
flags=0,
|
||||
add_buttons=(),
|
||||
@@ -104,8 +113,9 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
# Server List
|
||||
self.server_list = Gtk.ListBox(activate_on_single_click=False)
|
||||
self.server_list.connect(
|
||||
'selected-rows-changed', self.server_list_on_selected_rows_changed)
|
||||
self.server_list.connect('row-activated', self.on_server_list_activate)
|
||||
"selected-rows-changed", self.server_list_on_selected_rows_changed
|
||||
)
|
||||
self.server_list.connect("row-activated", self.on_server_list_activate)
|
||||
flowbox.pack_start(self.server_list, True, True, 10)
|
||||
|
||||
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
@@ -113,45 +123,47 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
# Add all of the buttons to the button box.
|
||||
self.buttons = [
|
||||
(
|
||||
IconButton(
|
||||
'document-edit-symbolic',
|
||||
label='Edit...',
|
||||
relief=True,
|
||||
), lambda e: self.on_edit_clicked(False), 'start', True),
|
||||
IconButton("document-edit-symbolic", label="Edit...", relief=True,),
|
||||
lambda e: self.on_edit_clicked(False),
|
||||
"start",
|
||||
True,
|
||||
),
|
||||
(
|
||||
IconButton("list-add-symbolic", label="Add...", relief=True,),
|
||||
lambda e: self.on_edit_clicked(True),
|
||||
"start",
|
||||
False,
|
||||
),
|
||||
(
|
||||
IconButton("list-remove-symbolic", label="Remove", relief=True,),
|
||||
self.on_remove_clicked,
|
||||
"start",
|
||||
True,
|
||||
),
|
||||
(
|
||||
IconButton("window-close-symbolic", label="Close", relief=True,),
|
||||
lambda _: self.close(),
|
||||
"end",
|
||||
False,
|
||||
),
|
||||
(
|
||||
IconButton(
|
||||
'list-add-symbolic',
|
||||
label='Add...',
|
||||
relief=True,
|
||||
), lambda e: self.on_edit_clicked(True), 'start', False),
|
||||
(
|
||||
IconButton(
|
||||
'list-remove-symbolic',
|
||||
label='Remove',
|
||||
relief=True,
|
||||
), self.on_remove_clicked, 'start', True),
|
||||
(
|
||||
IconButton(
|
||||
'window-close-symbolic',
|
||||
label='Close',
|
||||
relief=True,
|
||||
), lambda _: self.close(), 'end', False),
|
||||
(
|
||||
IconButton(
|
||||
'network-transmit-receive-symbolic',
|
||||
label='Connect',
|
||||
relief=True,
|
||||
), self.on_connect_clicked, 'end', True),
|
||||
"network-transmit-receive-symbolic", label="Connect", relief=True,
|
||||
),
|
||||
self.on_connect_clicked,
|
||||
"end",
|
||||
True,
|
||||
),
|
||||
]
|
||||
for button_cfg in self.buttons:
|
||||
btn, action, pack_end, requires_selection = button_cfg
|
||||
|
||||
if pack_end == 'end':
|
||||
if pack_end == "end":
|
||||
button_box.pack_end(btn, False, True, 5)
|
||||
else:
|
||||
button_box.pack_start(btn, False, True, 5)
|
||||
|
||||
btn.connect('clicked', action)
|
||||
btn.connect("clicked", action)
|
||||
|
||||
flowbox.pack_end(button_box, False, False, 0)
|
||||
|
||||
@@ -174,8 +186,7 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
image = Gtk.Image(margin=5)
|
||||
if i == self.selected_server_index:
|
||||
image.set_from_icon_name(
|
||||
'network-transmit-receive-symbolic',
|
||||
Gtk.IconSize.SMALL_TOOLBAR,
|
||||
"network-transmit-receive-symbolic", Gtk.IconSize.SMALL_TOOLBAR,
|
||||
)
|
||||
|
||||
box.add(image)
|
||||
@@ -187,41 +198,37 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
|
||||
# Show them, and select the current server.
|
||||
self.show_all()
|
||||
if (self.selected_server_index is not None
|
||||
and self.selected_server_index >= 0):
|
||||
if self.selected_server_index is not None and self.selected_server_index >= 0:
|
||||
self.server_list.select_row(
|
||||
self.server_list.get_row_at_index(self.selected_server_index))
|
||||
self.server_list.get_row_at_index(self.selected_server_index)
|
||||
)
|
||||
|
||||
def on_remove_clicked(self, event: Any):
|
||||
selected = self.server_list.get_selected_row()
|
||||
if selected:
|
||||
del self.server_configs[selected.get_index()]
|
||||
self.refresh_server_list()
|
||||
self.emit('server-list-changed', self.server_configs)
|
||||
self.emit("server-list-changed", self.server_configs)
|
||||
|
||||
def on_edit_clicked(self, add: bool):
|
||||
if add:
|
||||
dialog = EditServerDialog(self)
|
||||
else:
|
||||
selected_index = self.server_list.get_selected_row().get_index()
|
||||
dialog = EditServerDialog(
|
||||
self, self.server_configs[selected_index])
|
||||
dialog = EditServerDialog(self, self.server_configs[selected_index])
|
||||
|
||||
result = dialog.run()
|
||||
if result == Gtk.ResponseType.OK:
|
||||
# Create a new server configuration to use.
|
||||
new_config = ServerConfiguration(
|
||||
name=dialog.data['name'].get_text(),
|
||||
server_address=dialog.data['server_address'].get_text(),
|
||||
local_network_address=dialog.data['local_network_address']
|
||||
.get_text(),
|
||||
local_network_ssid=dialog.data['local_network_ssid'].get_text(
|
||||
),
|
||||
username=dialog.data['username'].get_text(),
|
||||
password=dialog.data['password'].get_text(),
|
||||
sync_enabled=dialog.data['sync_enabled'].get_active(),
|
||||
disable_cert_verify=dialog.data['disable_cert_verify']
|
||||
.get_active(),
|
||||
name=dialog.data["name"].get_text(),
|
||||
server_address=dialog.data["server_address"].get_text(),
|
||||
local_network_address=dialog.data["local_network_address"].get_text(),
|
||||
local_network_ssid=dialog.data["local_network_ssid"].get_text(),
|
||||
username=dialog.data["username"].get_text(),
|
||||
password=dialog.data["password"].get_text(),
|
||||
sync_enabled=dialog.data["sync_enabled"].get_active(),
|
||||
disable_cert_verify=dialog.data["disable_cert_verify"].get_active(),
|
||||
)
|
||||
|
||||
if add:
|
||||
@@ -230,7 +237,7 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
self.server_configs[selected_index] = new_config
|
||||
|
||||
self.refresh_server_list()
|
||||
self.emit('server-list-changed', self.server_configs)
|
||||
self.emit("server-list-changed", self.server_configs)
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
@@ -239,7 +246,7 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
|
||||
def on_connect_clicked(self, event: Any):
|
||||
selected_index = self.server_list.get_selected_row().get_index()
|
||||
self.emit('connected-server-changed', selected_index)
|
||||
self.emit("connected-server-changed", selected_index)
|
||||
self.close()
|
||||
|
||||
def server_list_on_selected_rows_changed(self, event: Any):
|
||||
|
@@ -2,39 +2,32 @@ from datetime import datetime
|
||||
from typing import Any, Callable, Set
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
|
||||
|
||||
from sublime.cache_manager import CacheManager, SearchResult
|
||||
from sublime.config import AppConfiguration
|
||||
from sublime.ui import (
|
||||
albums, artists, browse, player_controls, playlists, util)
|
||||
from sublime.ui import albums, artists, browse, player_controls, playlists, util
|
||||
from sublime.ui.common import SpinnerImage
|
||||
|
||||
|
||||
class MainWindow(Gtk.ApplicationWindow):
|
||||
"""Defines the main window for Sublime Music."""
|
||||
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'songs-removed': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, ),
|
||||
),
|
||||
'refresh-window': (
|
||||
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
),
|
||||
'go-to': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, str),
|
||||
),
|
||||
"go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str),),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -48,20 +41,20 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
Browse=browse.BrowsePanel(),
|
||||
Playlists=playlists.PlaylistsPanel(),
|
||||
)
|
||||
self.stack.set_transition_type(
|
||||
Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
||||
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
||||
|
||||
self.titlebar = self._create_headerbar(self.stack)
|
||||
self.set_titlebar(self.titlebar)
|
||||
|
||||
self.player_controls = player_controls.PlayerControls()
|
||||
self.player_controls.connect(
|
||||
'song-clicked', lambda _, *a: self.emit('song-clicked', *a))
|
||||
"song-clicked", lambda _, *a: self.emit("song-clicked", *a)
|
||||
)
|
||||
self.player_controls.connect(
|
||||
'songs-removed', lambda _, *a: self.emit('songs-removed', *a))
|
||||
"songs-removed", lambda _, *a: self.emit("songs-removed", *a)
|
||||
)
|
||||
self.player_controls.connect(
|
||||
'refresh-window',
|
||||
lambda _, *args: self.emit('refresh-window', *args),
|
||||
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
|
||||
)
|
||||
|
||||
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
@@ -69,21 +62,23 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
flowbox.pack_start(self.player_controls, False, True, 0)
|
||||
self.add(flowbox)
|
||||
|
||||
self.connect('button-release-event', self._on_button_release)
|
||||
self.connect("button-release-event", self._on_button_release)
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
# Update the Connected to label on the popup menu.
|
||||
if app_config.server:
|
||||
self.connected_to_label.set_markup(
|
||||
f'<b>Connected to {app_config.server.name}</b>')
|
||||
f"<b>Connected to {app_config.server.name}</b>"
|
||||
)
|
||||
else:
|
||||
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)
|
||||
|
||||
active_panel = self.stack.get_visible_child()
|
||||
if hasattr(active_panel, 'update'):
|
||||
if hasattr(active_panel, "update"):
|
||||
active_panel.update(app_config, force=force)
|
||||
|
||||
self.player_controls.update(app_config)
|
||||
@@ -92,12 +87,10 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
stack = Gtk.Stack()
|
||||
for name, child in kwargs.items():
|
||||
child.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
child.connect(
|
||||
'refresh-window',
|
||||
lambda _, *args: self.emit('refresh-window', *args),
|
||||
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
|
||||
)
|
||||
stack.add_titled(child, name.lower(), name)
|
||||
return stack
|
||||
@@ -108,20 +101,17 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
"""
|
||||
header = Gtk.HeaderBar()
|
||||
header.set_show_close_button(True)
|
||||
header.props.title = 'Sublime Music'
|
||||
header.props.title = "Sublime Music"
|
||||
|
||||
# Search
|
||||
self.search_entry = Gtk.SearchEntry(
|
||||
placeholder_text='Search everything...')
|
||||
self.search_entry = Gtk.SearchEntry(placeholder_text="Search everything...")
|
||||
self.search_entry.connect("focus-in-event", self._on_search_entry_focus)
|
||||
self.search_entry.connect(
|
||||
'focus-in-event', self._on_search_entry_focus)
|
||||
self.search_entry.connect(
|
||||
'button-press-event', self._on_search_entry_button_press)
|
||||
self.search_entry.connect(
|
||||
'focus-out-event', self._on_search_entry_loose_focus)
|
||||
self.search_entry.connect('changed', self._on_search_entry_changed)
|
||||
self.search_entry.connect(
|
||||
'stop-search', self._on_search_entry_stop_search)
|
||||
"button-press-event", self._on_search_entry_button_press
|
||||
)
|
||||
self.search_entry.connect("focus-out-event", self._on_search_entry_loose_focus)
|
||||
self.search_entry.connect("changed", self._on_search_entry_changed)
|
||||
self.search_entry.connect("stop-search", self._on_search_entry_stop_search)
|
||||
header.pack_start(self.search_entry)
|
||||
|
||||
# Search popup
|
||||
@@ -133,13 +123,13 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
|
||||
# Menu button
|
||||
menu_button = Gtk.MenuButton()
|
||||
menu_button.set_tooltip_text('Open application menu')
|
||||
menu_button.set_tooltip_text("Open application menu")
|
||||
menu_button.set_use_popover(True)
|
||||
menu_button.set_popover(self._create_menu())
|
||||
menu_button.connect('clicked', self._on_menu_clicked)
|
||||
menu_button.connect("clicked", self._on_menu_clicked)
|
||||
self.menu.set_relative_to(menu_button)
|
||||
|
||||
icon = Gio.ThemedIcon(name='open-menu-symbolic')
|
||||
icon = Gio.ThemedIcon(name="open-menu-symbolic")
|
||||
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
|
||||
menu_button.add(image)
|
||||
|
||||
@@ -156,31 +146,28 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
**kwargs,
|
||||
)
|
||||
label.set_markup(text)
|
||||
label.get_style_context().add_class('search-result-row')
|
||||
label.get_style_context().add_class("search-result-row")
|
||||
return label
|
||||
|
||||
def _create_menu(self) -> Gtk.PopoverMenu:
|
||||
self.menu = Gtk.PopoverMenu()
|
||||
|
||||
self.connected_to_label = self._create_label(
|
||||
'', name='connected-to-label')
|
||||
self.connected_to_label = self._create_label("", name="connected-to-label")
|
||||
self.connected_to_label.set_markup(
|
||||
f'<span style="italic">Not Connected to a Server</span>')
|
||||
'<span style="italic">Not Connected to a Server</span>'
|
||||
)
|
||||
|
||||
menu_items = [
|
||||
(None, self.connected_to_label),
|
||||
(
|
||||
'app.configure-servers',
|
||||
Gtk.ModelButton(text='Configure Servers'),
|
||||
),
|
||||
('app.settings', Gtk.ModelButton(text='Settings')),
|
||||
("app.configure-servers", Gtk.ModelButton(text="Configure Servers"),),
|
||||
("app.settings", Gtk.ModelButton(text="Settings")),
|
||||
]
|
||||
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
for name, item in menu_items:
|
||||
if name:
|
||||
item.set_action_name(name)
|
||||
item.get_style_context().add_class('menu-button')
|
||||
item.get_style_context().add_class("menu-button")
|
||||
vbox.pack_start(item, False, True, 0)
|
||||
self.menu.add(vbox)
|
||||
|
||||
@@ -190,36 +177,33 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
self.search_popup = Gtk.PopoverMenu(modal=False)
|
||||
|
||||
results_scrollbox = Gtk.ScrolledWindow(
|
||||
min_content_width=500,
|
||||
min_content_height=750,
|
||||
min_content_width=500, min_content_height=750,
|
||||
)
|
||||
|
||||
def make_search_result_header(text: str) -> Gtk.Label:
|
||||
label = self._create_label(text)
|
||||
label.get_style_context().add_class('search-result-header')
|
||||
label.get_style_context().add_class("search-result-header")
|
||||
return label
|
||||
|
||||
search_results_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
name='search-results',
|
||||
orientation=Gtk.Orientation.VERTICAL, name="search-results",
|
||||
)
|
||||
self.search_results_loading = Gtk.Spinner(
|
||||
active=False, name='search-spinner')
|
||||
self.search_results_loading = Gtk.Spinner(active=False, name="search-spinner")
|
||||
search_results_box.add(self.search_results_loading)
|
||||
|
||||
search_results_box.add(make_search_result_header('Songs'))
|
||||
search_results_box.add(make_search_result_header("Songs"))
|
||||
self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.song_results)
|
||||
|
||||
search_results_box.add(make_search_result_header('Albums'))
|
||||
search_results_box.add(make_search_result_header("Albums"))
|
||||
self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.album_results)
|
||||
|
||||
search_results_box.add(make_search_result_header('Artists'))
|
||||
search_results_box.add(make_search_result_header("Artists"))
|
||||
self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.artist_results)
|
||||
|
||||
search_results_box.add(make_search_result_header('Playlists'))
|
||||
search_results_box.add(make_search_result_header("Playlists"))
|
||||
self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.playlist_results)
|
||||
|
||||
@@ -238,24 +222,20 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
# Event Listeners
|
||||
# =========================================================================
|
||||
def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool:
|
||||
if not self._event_in_widgets(
|
||||
event,
|
||||
self.search_entry,
|
||||
self.search_popup,
|
||||
):
|
||||
if not self._event_in_widgets(event, self.search_entry, self.search_popup,):
|
||||
self._hide_search()
|
||||
|
||||
if not self._event_in_widgets(
|
||||
event,
|
||||
self.player_controls.device_button,
|
||||
self.player_controls.device_popover,
|
||||
event,
|
||||
self.player_controls.device_button,
|
||||
self.player_controls.device_popover,
|
||||
):
|
||||
self.player_controls.device_popover.popdown()
|
||||
|
||||
if not self._event_in_widgets(
|
||||
event,
|
||||
self.player_controls.play_queue_button,
|
||||
self.player_controls.play_queue_popover,
|
||||
event,
|
||||
self.player_controls.play_queue_button,
|
||||
self.player_controls.play_queue_popover,
|
||||
):
|
||||
self.player_controls.play_queue_popover.popdown()
|
||||
|
||||
@@ -294,8 +274,7 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
|
||||
def create_search_callback(idx: int) -> Callable[..., Any]:
|
||||
def search_result_calback(
|
||||
result: SearchResult,
|
||||
is_last_in_batch: bool,
|
||||
result: SearchResult, is_last_in_batch: bool,
|
||||
):
|
||||
# Ignore slow returned searches.
|
||||
if idx < self.latest_returned_search_idx:
|
||||
@@ -316,7 +295,8 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
entry.get_text(),
|
||||
search_callback=create_search_callback(self.search_idx),
|
||||
before_download=lambda: self._set_search_loading(True),
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
self.search_idx += 1
|
||||
|
||||
@@ -348,25 +328,25 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
widget.remove(c)
|
||||
|
||||
def _create_search_result_row(
|
||||
self,
|
||||
text: str,
|
||||
action_name: str,
|
||||
value: Any,
|
||||
artwork_future: CacheManager.Result,
|
||||
self,
|
||||
text: str,
|
||||
action_name: str,
|
||||
value: Any,
|
||||
artwork_future: CacheManager.Result,
|
||||
) -> Gtk.Button:
|
||||
def on_search_row_button_press(*args):
|
||||
if action_name == 'song':
|
||||
goto_action_name, goto_id = 'album', value.albumId
|
||||
if action_name == "song":
|
||||
goto_action_name, goto_id = "album", value.albumId
|
||||
else:
|
||||
goto_action_name, goto_id = action_name, value.id
|
||||
self.emit('go-to', goto_action_name, goto_id)
|
||||
self.emit("go-to", goto_action_name, goto_id)
|
||||
self._hide_search()
|
||||
|
||||
row = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
|
||||
row.connect('button-press-event', on_search_row_button_press)
|
||||
row.connect("button-press-event", on_search_row_button_press)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
image = SpinnerImage(image_name='search-artwork', image_size=30)
|
||||
image = SpinnerImage(image_name="search-artwork", image_size=30)
|
||||
box.add(image)
|
||||
box.add(self._create_label(text))
|
||||
row.add(box)
|
||||
@@ -375,8 +355,7 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
image.set_loading(False)
|
||||
image.set_from_file(f.result())
|
||||
|
||||
artwork_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(image_callback, f))
|
||||
artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f))
|
||||
|
||||
return row
|
||||
|
||||
@@ -386,14 +365,14 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
self._remove_all_from_widget(self.song_results)
|
||||
for song in search_results.song or []:
|
||||
label_text = util.dot_join(
|
||||
f'<b>{util.esc(song.title)}</b>',
|
||||
util.esc(song.artist),
|
||||
f"<b>{util.esc(song.title)}</b>", util.esc(song.artist),
|
||||
)
|
||||
cover_art_future = CacheManager.get_cover_art_filename(
|
||||
song.coverArt)
|
||||
cover_art_future = CacheManager.get_cover_art_filename(song.coverArt)
|
||||
self.song_results.add(
|
||||
self._create_search_result_row(
|
||||
label_text, 'song', song, cover_art_future))
|
||||
label_text, "song", song, cover_art_future
|
||||
)
|
||||
)
|
||||
|
||||
self.song_results.show_all()
|
||||
|
||||
@@ -402,14 +381,14 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
self._remove_all_from_widget(self.album_results)
|
||||
for album in search_results.album or []:
|
||||
label_text = util.dot_join(
|
||||
f'<b>{util.esc(album.name)}</b>',
|
||||
util.esc(album.artist),
|
||||
f"<b>{util.esc(album.name)}</b>", util.esc(album.artist),
|
||||
)
|
||||
cover_art_future = CacheManager.get_cover_art_filename(
|
||||
album.coverArt)
|
||||
cover_art_future = CacheManager.get_cover_art_filename(album.coverArt)
|
||||
self.album_results.add(
|
||||
self._create_search_result_row(
|
||||
label_text, 'album', album, cover_art_future))
|
||||
label_text, "album", album, cover_art_future
|
||||
)
|
||||
)
|
||||
|
||||
self.album_results.show_all()
|
||||
|
||||
@@ -421,7 +400,9 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
cover_art_future = CacheManager.get_artist_artwork(artist)
|
||||
self.artist_results.add(
|
||||
self._create_search_result_row(
|
||||
label_text, 'artist', artist, cover_art_future))
|
||||
label_text, "artist", artist, cover_art_future
|
||||
)
|
||||
)
|
||||
|
||||
self.artist_results.show_all()
|
||||
|
||||
@@ -431,10 +412,13 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
for playlist in search_results.playlist or []:
|
||||
label_text = util.esc(playlist.name)
|
||||
cover_art_future = CacheManager.get_cover_art_filename(
|
||||
playlist.coverArt)
|
||||
playlist.coverArt
|
||||
)
|
||||
self.playlist_results.add(
|
||||
self._create_search_result_row(
|
||||
label_text, 'playlist', playlist, cover_art_future))
|
||||
label_text, "playlist", playlist, cover_art_future
|
||||
)
|
||||
)
|
||||
|
||||
self.playlist_results.show_all()
|
||||
|
||||
@@ -451,8 +435,9 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
bound_y = (win_y + widget_y, win_y + widget_y + allocation.height)
|
||||
|
||||
# If the event is in this widget, return True immediately.
|
||||
if ((bound_x[0] <= event.x_root <= bound_x[1])
|
||||
and (bound_y[0] <= event.y_root <= bound_y[1])):
|
||||
if (bound_x[0] <= event.x_root <= bound_x[1]) and (
|
||||
bound_y[0] <= event.y_root <= bound_y[1]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
@@ -5,7 +5,8 @@ from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
|
||||
from pychromecast import Chromecast
|
||||
|
||||
@@ -22,33 +23,18 @@ class PlayerControls(Gtk.ActionBar):
|
||||
"""
|
||||
Defines the player controls panel that appears at the bottom of the window.
|
||||
"""
|
||||
|
||||
__gsignals__ = {
|
||||
'song-scrub': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(float, ),
|
||||
),
|
||||
'volume-change': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(float, ),
|
||||
),
|
||||
'device-update': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, ),
|
||||
),
|
||||
'song-clicked': (
|
||||
"song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),),
|
||||
"volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),),
|
||||
"device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,),),
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'songs-removed': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, ),
|
||||
),
|
||||
'refresh-window': (
|
||||
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -66,7 +52,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
def __init__(self):
|
||||
Gtk.ActionBar.__init__(self)
|
||||
self.set_name('player-controls-bar')
|
||||
self.set_name("player-controls-bar")
|
||||
|
||||
song_display = self.create_song_display()
|
||||
playback_controls = self.create_playback_controls()
|
||||
@@ -83,34 +69,40 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
duration = (
|
||||
app_config.state.current_song.duration
|
||||
if app_config.state.current_song else None)
|
||||
if app_config.state.current_song
|
||||
else None
|
||||
)
|
||||
self.update_scrubber(app_config.state.song_progress, duration)
|
||||
|
||||
icon = 'pause' if app_config.state.playing else 'start'
|
||||
icon = "pause" if app_config.state.playing else "start"
|
||||
self.play_button.set_icon(f"media-playback-{icon}-symbolic")
|
||||
self.play_button.set_tooltip_text(
|
||||
'Pause' if app_config.state.playing else 'Play')
|
||||
"Pause" if app_config.state.playing else "Play"
|
||||
)
|
||||
|
||||
has_current_song = app_config.state.current_song is not None
|
||||
has_next_song = False
|
||||
if app_config.state.repeat_type in (RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG):
|
||||
if app_config.state.repeat_type in (
|
||||
RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG,
|
||||
):
|
||||
has_next_song = True
|
||||
elif has_current_song:
|
||||
last_idx_in_queue = len(app_config.state.play_queue) - 1
|
||||
has_next_song = (
|
||||
app_config.state.current_song_index < last_idx_in_queue)
|
||||
has_next_song = app_config.state.current_song_index < last_idx_in_queue
|
||||
|
||||
# Toggle button states.
|
||||
self.repeat_button.set_action_name(None)
|
||||
self.shuffle_button.set_action_name(None)
|
||||
repeat_on = app_config.state.repeat_type in (
|
||||
RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG)
|
||||
RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG,
|
||||
)
|
||||
self.repeat_button.set_active(repeat_on)
|
||||
self.repeat_button.set_icon(app_config.state.repeat_type.icon)
|
||||
self.shuffle_button.set_active(app_config.state.shuffle_on)
|
||||
self.repeat_button.set_action_name('app.repeat-press')
|
||||
self.shuffle_button.set_action_name('app.shuffle-press')
|
||||
self.repeat_button.set_action_name("app.repeat-press")
|
||||
self.shuffle_button.set_action_name("app.shuffle-press")
|
||||
|
||||
self.song_scrubber.set_sensitive(has_current_song)
|
||||
self.prev_button.set_sensitive(has_current_song)
|
||||
@@ -119,19 +111,20 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
# Volume button and slider
|
||||
if app_config.state.is_muted:
|
||||
icon_name = 'muted'
|
||||
icon_name = "muted"
|
||||
elif app_config.state.volume < 30:
|
||||
icon_name = 'low'
|
||||
icon_name = "low"
|
||||
elif app_config.state.volume < 70:
|
||||
icon_name = 'medium'
|
||||
icon_name = "medium"
|
||||
else:
|
||||
icon_name = 'high'
|
||||
icon_name = "high"
|
||||
|
||||
self.volume_mute_toggle.set_icon(f'audio-volume-{icon_name}-symbolic')
|
||||
self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
|
||||
|
||||
self.editing = True
|
||||
self.volume_slider.set_value(
|
||||
0 if app_config.state.is_muted else app_config.state.volume)
|
||||
0 if app_config.state.is_muted else app_config.state.volume
|
||||
)
|
||||
self.editing = False
|
||||
|
||||
# Update the current song information.
|
||||
@@ -143,19 +136,17 @@ class PlayerControls(Gtk.ActionBar):
|
||||
order_token=self.cover_art_update_order_token,
|
||||
)
|
||||
|
||||
self.song_title.set_markup(
|
||||
util.esc(app_config.state.current_song.title))
|
||||
self.album_name.set_markup(
|
||||
util.esc(app_config.state.current_song.album))
|
||||
self.song_title.set_markup(util.esc(app_config.state.current_song.title))
|
||||
self.album_name.set_markup(util.esc(app_config.state.current_song.album))
|
||||
artist_name = util.esc(app_config.state.current_song.artist)
|
||||
self.artist_name.set_markup(artist_name or '')
|
||||
self.artist_name.set_markup(artist_name or "")
|
||||
else:
|
||||
# Clear out the cover art and song tite if no song
|
||||
self.album_art.set_from_file(None)
|
||||
self.album_art.set_loading(False)
|
||||
self.song_title.set_markup('')
|
||||
self.album_name.set_markup('')
|
||||
self.artist_name.set_markup('')
|
||||
self.song_title.set_markup("")
|
||||
self.album_name.set_markup("")
|
||||
self.artist_name.set_markup("")
|
||||
|
||||
if self.devices_requested:
|
||||
self.update_device_list()
|
||||
@@ -163,11 +154,12 @@ class PlayerControls(Gtk.ActionBar):
|
||||
# Set the Play Queue button popup.
|
||||
play_queue_len = len(app_config.state.play_queue)
|
||||
if play_queue_len == 0:
|
||||
self.popover_label.set_markup('<b>Play Queue</b>')
|
||||
self.popover_label.set_markup("<b>Play Queue</b>")
|
||||
else:
|
||||
song_label = util.pluralize('song', play_queue_len)
|
||||
song_label = util.pluralize("song", play_queue_len)
|
||||
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
|
||||
|
||||
@@ -177,19 +169,15 @@ class PlayerControls(Gtk.ActionBar):
|
||||
title = util.esc(song_details.title)
|
||||
album = util.esc(song_details.album)
|
||||
artist = util.esc(song_details.artist)
|
||||
return f'<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(
|
||||
idx: int,
|
||||
order_tok: int,
|
||||
fn: Callable[[int, int, Any], None],
|
||||
idx: int, order_tok: int, fn: Callable[[int, int, Any], None],
|
||||
) -> Callable[[CacheManager.Result], None]:
|
||||
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
|
||||
|
||||
def on_cover_art_future_done(
|
||||
idx: int,
|
||||
order_token: int,
|
||||
cover_art_filename: str,
|
||||
idx: int, order_token: int, cover_art_filename: str,
|
||||
):
|
||||
if order_token != self.play_queue_update_order_token:
|
||||
return
|
||||
@@ -197,9 +185,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.play_queue_store[idx][0] = cover_art_filename
|
||||
|
||||
def on_song_details_future_done(
|
||||
idx: int,
|
||||
order_token: int,
|
||||
song_details: Child,
|
||||
idx: int, order_token: int, song_details: Child,
|
||||
):
|
||||
if order_token != self.play_queue_update_order_token:
|
||||
return
|
||||
@@ -208,15 +194,15 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
# Cover Art
|
||||
cover_art_result = CacheManager.get_cover_art_filename(
|
||||
song_details.coverArt)
|
||||
song_details.coverArt
|
||||
)
|
||||
if cover_art_result.is_future:
|
||||
# We don't have the cover art already cached.
|
||||
cover_art_result.add_done_callback(
|
||||
make_idle_index_capturing_function(
|
||||
idx,
|
||||
order_token,
|
||||
on_cover_art_future_done,
|
||||
))
|
||||
idx, order_token, on_cover_art_future_done,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# We have the cover art already cached.
|
||||
self.play_queue_store[idx][0] = cover_art_result.result()
|
||||
@@ -229,8 +215,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
for i, song_id in enumerate(app_config.state.play_queue):
|
||||
song_details_result = CacheManager.get_song_details(song_id)
|
||||
|
||||
cover_art_filename = ''
|
||||
label = '\n'
|
||||
cover_art_filename = ""
|
||||
label = "\n"
|
||||
|
||||
if song_details_result.is_future:
|
||||
song_details_results.append((i, song_details_result))
|
||||
@@ -240,7 +226,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
label = calculate_label(song_details)
|
||||
|
||||
cover_art_result = CacheManager.get_cover_art_filename(
|
||||
song_details.coverArt)
|
||||
song_details.coverArt
|
||||
)
|
||||
if cover_art_result.is_future:
|
||||
# We don't have the cover art already cached.
|
||||
cover_art_result.add_done_callback(
|
||||
@@ -248,7 +235,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
i,
|
||||
self.play_queue_update_order_token,
|
||||
on_cover_art_future_done,
|
||||
))
|
||||
)
|
||||
)
|
||||
else:
|
||||
# We have the cover art already cached.
|
||||
cover_art_filename = cover_art_result.result()
|
||||
@@ -259,7 +247,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
label,
|
||||
i == app_config.state.current_song_index,
|
||||
song_id,
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
util.diff_song_store(self.play_queue_store, new_store)
|
||||
|
||||
@@ -270,7 +259,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
idx,
|
||||
self.play_queue_update_order_token,
|
||||
on_song_details_future_done,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
self.editing_play_queue_song_list = False
|
||||
|
||||
@@ -293,13 +283,11 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.album_art.set_loading(False)
|
||||
|
||||
def update_scrubber(
|
||||
self,
|
||||
current: Optional[float],
|
||||
duration: Optional[int],
|
||||
self, current: Optional[float], duration: Optional[int],
|
||||
):
|
||||
if current is None or duration is None:
|
||||
self.song_duration_label.set_text('-:--')
|
||||
self.song_progress_label.set_text('-:--')
|
||||
self.song_duration_label.set_text("-:--")
|
||||
self.song_progress_label.set_text("-:--")
|
||||
self.song_scrubber.set_value(0)
|
||||
return
|
||||
|
||||
@@ -310,11 +298,12 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.song_scrubber.set_value(percent_complete)
|
||||
self.song_duration_label.set_text(util.format_song_duration(duration))
|
||||
self.song_progress_label.set_text(
|
||||
util.format_song_duration(math.floor(current)))
|
||||
util.format_song_duration(math.floor(current))
|
||||
)
|
||||
|
||||
def on_volume_change(self, scale: Gtk.Scale):
|
||||
if not self.editing:
|
||||
self.emit('volume-change', scale.get_value())
|
||||
self.emit("volume-change", scale.get_value())
|
||||
|
||||
def on_play_queue_click(self, _: Any):
|
||||
if self.play_queue_popover.is_visible():
|
||||
@@ -327,10 +316,10 @@ class PlayerControls(Gtk.ActionBar):
|
||||
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
|
||||
# The song ID is in the last column of the model.
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
"song-clicked",
|
||||
idx.get_indices()[0],
|
||||
[m[-1] for m in self.play_queue_store],
|
||||
{'no_reshuffle': True},
|
||||
{"no_reshuffle": True},
|
||||
)
|
||||
|
||||
def update_device_list(self, force: bool = False):
|
||||
@@ -341,21 +330,23 @@ class PlayerControls(Gtk.ActionBar):
|
||||
for c in self.chromecast_device_list.get_children():
|
||||
self.chromecast_device_list.remove(c)
|
||||
|
||||
if self.current_device == 'this device':
|
||||
self.this_device.set_icon('audio-volume-high-symbolic')
|
||||
if self.current_device == "this device":
|
||||
self.this_device.set_icon("audio-volume-high-symbolic")
|
||||
else:
|
||||
self.this_device.set_icon(None)
|
||||
|
||||
chromecasts.sort(key=lambda c: c.device.friendly_name)
|
||||
for cc in chromecasts:
|
||||
icon = (
|
||||
'audio-volume-high-symbolic'
|
||||
if str(cc.device.uuid) == self.current_device else None)
|
||||
"audio-volume-high-symbolic"
|
||||
if str(cc.device.uuid) == self.current_device
|
||||
else None
|
||||
)
|
||||
btn = IconButton(icon, label=cc.device.friendly_name)
|
||||
btn.get_style_context().add_class('menu-button')
|
||||
btn.get_style_context().add_class("menu-button")
|
||||
btn.connect(
|
||||
'clicked',
|
||||
lambda _, uuid: self.emit('device-update', uuid),
|
||||
"clicked",
|
||||
lambda _, uuid: self.emit("device-update", uuid),
|
||||
cc.device.uuid,
|
||||
)
|
||||
self.chromecast_device_list.add(btn)
|
||||
@@ -366,12 +357,13 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
update_diff = (
|
||||
self.last_device_list_update
|
||||
and (datetime.now() - self.last_device_list_update).seconds > 60)
|
||||
if (force or len(self.chromecasts) == 0
|
||||
or (update_diff and update_diff > 60)):
|
||||
and (datetime.now() - self.last_device_list_update).seconds > 60
|
||||
)
|
||||
if force or len(self.chromecasts) == 0 or (update_diff and update_diff > 60):
|
||||
future = ChromecastPlayer.get_chromecasts()
|
||||
future.add_done_callback(
|
||||
lambda f: GLib.idle_add(chromecast_callback, f.result()))
|
||||
lambda f: GLib.idle_add(chromecast_callback, f.result())
|
||||
)
|
||||
else:
|
||||
chromecast_callback(self.chromecasts)
|
||||
|
||||
@@ -387,11 +379,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
def on_device_refresh_click(self, _: Any):
|
||||
self.update_device_list(force=True)
|
||||
|
||||
def on_play_queue_button_press(
|
||||
self,
|
||||
tree: Any,
|
||||
event: Gdk.EventButton,
|
||||
) -> bool:
|
||||
def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton,) -> bool:
|
||||
if event.button == 3: # Right click
|
||||
clicked_path = tree.get_path_at_pos(event.x, event.y)
|
||||
|
||||
@@ -401,7 +389,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
def on_download_state_change():
|
||||
# Refresh the entire window (no force) because the song could
|
||||
# be in a list anywhere in the window.
|
||||
self.emit('refresh-window', {}, False)
|
||||
self.emit("refresh-window", {}, False)
|
||||
|
||||
# Use the new selection instead of the old one for calculating what
|
||||
# to do the right click on.
|
||||
@@ -412,11 +400,11 @@ class PlayerControls(Gtk.ActionBar):
|
||||
song_ids = [self.play_queue_store[p][-1] for p in paths]
|
||||
|
||||
remove_text = (
|
||||
'Remove ' + util.pluralize('song', len(song_ids))
|
||||
+ ' from queue')
|
||||
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
|
||||
)
|
||||
|
||||
def on_remove_songs_click(_: Any):
|
||||
self.emit('songs-removed', [p.get_indices()[0] for p in paths])
|
||||
self.emit("songs-removed", [p.get_indices()[0] for p in paths])
|
||||
|
||||
util.show_song_popover(
|
||||
song_ids,
|
||||
@@ -448,10 +436,10 @@ class PlayerControls(Gtk.ActionBar):
|
||||
i for i, s in enumerate(self.play_queue_store) if s[2]
|
||||
][0]
|
||||
self.emit(
|
||||
'refresh-window',
|
||||
"refresh-window",
|
||||
{
|
||||
'current_song_index': currently_playing_index,
|
||||
'play_queue': [s[-1] for s in self.play_queue_store],
|
||||
"current_song_index": currently_playing_index,
|
||||
"play_queue": [s[-1] for s in self.play_queue_store],
|
||||
},
|
||||
False,
|
||||
)
|
||||
@@ -463,8 +451,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.album_art = SpinnerImage(
|
||||
image_name='player-controls-album-artwork',
|
||||
image_size=70,
|
||||
image_name="player-controls-album-artwork", image_size=70,
|
||||
)
|
||||
box.pack_start(self.album_art, False, False, 5)
|
||||
|
||||
@@ -480,13 +467,13 @@ class PlayerControls(Gtk.ActionBar):
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
)
|
||||
|
||||
self.song_title = make_label('song-title')
|
||||
self.song_title = make_label("song-title")
|
||||
details_box.add(self.song_title)
|
||||
|
||||
self.album_name = make_label('album-name')
|
||||
self.album_name = make_label("album-name")
|
||||
details_box.add(self.album_name)
|
||||
|
||||
self.artist_name = make_label('artist-name')
|
||||
self.artist_name = make_label("artist-name")
|
||||
details_box.add(self.artist_name)
|
||||
|
||||
details_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
@@ -500,18 +487,20 @@ class PlayerControls(Gtk.ActionBar):
|
||||
# Scrubber and song progress/length labels
|
||||
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.song_progress_label = Gtk.Label(label='-:--')
|
||||
self.song_progress_label = Gtk.Label(label="-:--")
|
||||
scrubber_box.pack_start(self.song_progress_label, False, False, 5)
|
||||
|
||||
self.song_scrubber = Gtk.Scale.new_with_range(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5)
|
||||
self.song_scrubber.set_name('song-scrubber')
|
||||
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
|
||||
)
|
||||
self.song_scrubber.set_name("song-scrubber")
|
||||
self.song_scrubber.set_draw_value(False)
|
||||
self.song_scrubber.connect(
|
||||
'change-value', lambda s, t, v: self.emit('song-scrub', v))
|
||||
"change-value", lambda s, t, v: self.emit("song-scrub", v)
|
||||
)
|
||||
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
|
||||
|
||||
self.song_duration_label = Gtk.Label(label='-:--')
|
||||
self.song_duration_label = Gtk.Label(label="-:--")
|
||||
scrubber_box.pack_start(self.song_duration_label, False, False, 5)
|
||||
|
||||
box.add(scrubber_box)
|
||||
@@ -522,8 +511,9 @@ class PlayerControls(Gtk.ActionBar):
|
||||
# Repeat button
|
||||
repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.repeat_button = IconToggleButton(
|
||||
'media-playlist-repeat', 'Switch between repeat modes')
|
||||
self.repeat_button.set_action_name('app.repeat-press')
|
||||
"media-playlist-repeat", "Switch between repeat modes"
|
||||
)
|
||||
self.repeat_button.set_action_name("app.repeat-press")
|
||||
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
repeat_button_box.pack_start(self.repeat_button, False, False, 0)
|
||||
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
@@ -531,35 +521,39 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
# Previous button
|
||||
self.prev_button = IconButton(
|
||||
'media-skip-backward-symbolic',
|
||||
'Go to previous song',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.prev_button.set_action_name('app.prev-track')
|
||||
"media-skip-backward-symbolic",
|
||||
"Go to previous song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.prev_button.set_action_name("app.prev-track")
|
||||
buttons.pack_start(self.prev_button, False, False, 5)
|
||||
|
||||
# Play button
|
||||
self.play_button = IconButton(
|
||||
'media-playback-start-symbolic',
|
||||
'Play',
|
||||
"media-playback-start-symbolic",
|
||||
"Play",
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.play_button.set_name('play-button')
|
||||
self.play_button.set_action_name('app.play-pause')
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.play_button.set_name("play-button")
|
||||
self.play_button.set_action_name("app.play-pause")
|
||||
buttons.pack_start(self.play_button, False, False, 0)
|
||||
|
||||
# Next button
|
||||
self.next_button = IconButton(
|
||||
'media-skip-forward-symbolic',
|
||||
'Go to next song',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.next_button.set_action_name('app.next-track')
|
||||
"media-skip-forward-symbolic",
|
||||
"Go to next song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.next_button.set_action_name("app.next-track")
|
||||
buttons.pack_start(self.next_button, False, False, 5)
|
||||
|
||||
# Shuffle button
|
||||
shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.shuffle_button = IconToggleButton(
|
||||
'media-playlist-shuffle-symbolic', 'Toggle playlist shuffling')
|
||||
self.shuffle_button.set_action_name('app.shuffle-press')
|
||||
"media-playlist-shuffle-symbolic", "Toggle playlist shuffling"
|
||||
)
|
||||
self.shuffle_button.set_action_name("app.shuffle-press")
|
||||
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
shuffle_button_box.pack_start(self.shuffle_button, False, False, 0)
|
||||
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
@@ -577,36 +571,28 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
# Device button (for chromecast)
|
||||
self.device_button = IconButton(
|
||||
'video-display-symbolic',
|
||||
'Show available audio output devices',
|
||||
"video-display-symbolic",
|
||||
"Show available audio output devices",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.device_button.connect('clicked', self.on_device_click)
|
||||
self.device_button.connect("clicked", self.on_device_click)
|
||||
box.pack_start(self.device_button, False, True, 5)
|
||||
|
||||
self.device_popover = Gtk.PopoverMenu(
|
||||
modal=False,
|
||||
name='device-popover',
|
||||
)
|
||||
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover",)
|
||||
self.device_popover.set_relative_to(self.device_button)
|
||||
|
||||
device_popover_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
name='device-popover-box',
|
||||
orientation=Gtk.Orientation.VERTICAL, name="device-popover-box",
|
||||
)
|
||||
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.popover_label = Gtk.Label(
|
||||
label='<b>Devices</b>',
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=5,
|
||||
label="<b>Devices</b>", use_markup=True, halign=Gtk.Align.START, margin=5,
|
||||
)
|
||||
device_popover_header.add(self.popover_label)
|
||||
|
||||
refresh_devices = IconButton(
|
||||
'view-refresh-symbolic', 'Refresh device list')
|
||||
refresh_devices.connect('clicked', self.on_device_refresh_click)
|
||||
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
|
||||
refresh_devices.connect("clicked", self.on_device_refresh_click)
|
||||
device_popover_header.pack_end(refresh_devices, False, False, 0)
|
||||
|
||||
device_popover_box.add(device_popover_header)
|
||||
@@ -614,22 +600,21 @@ class PlayerControls(Gtk.ActionBar):
|
||||
device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.this_device = IconButton(
|
||||
'audio-volume-high-symbolic',
|
||||
label='This Device',
|
||||
"audio-volume-high-symbolic", label="This Device",
|
||||
)
|
||||
self.this_device.get_style_context().add_class('menu-button')
|
||||
self.this_device.get_style_context().add_class("menu-button")
|
||||
self.this_device.connect(
|
||||
'clicked', lambda *a: self.emit('device-update', 'this device'))
|
||||
"clicked", lambda *a: self.emit("device-update", "this device")
|
||||
)
|
||||
device_list.add(self.this_device)
|
||||
|
||||
device_list.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
||||
|
||||
self.device_list_loading = Gtk.Spinner(active=True)
|
||||
self.device_list_loading.get_style_context().add_class('menu-button')
|
||||
self.device_list_loading.get_style_context().add_class("menu-button")
|
||||
device_list.add(self.device_list_loading)
|
||||
|
||||
self.chromecast_device_list = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL)
|
||||
self.chromecast_device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
device_list.add(self.chromecast_device_list)
|
||||
|
||||
device_popover_box.pack_end(device_list, True, True, 0)
|
||||
@@ -638,25 +623,21 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
# Play Queue button
|
||||
self.play_queue_button = IconButton(
|
||||
'view-list-symbolic',
|
||||
'Open play queue',
|
||||
"view-list-symbolic",
|
||||
"Open play queue",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.play_queue_button.connect('clicked', self.on_play_queue_click)
|
||||
self.play_queue_button.connect("clicked", self.on_play_queue_click)
|
||||
box.pack_start(self.play_queue_button, False, True, 5)
|
||||
|
||||
self.play_queue_popover = Gtk.PopoverMenu(
|
||||
modal=False,
|
||||
name='up-next-popover',
|
||||
)
|
||||
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover",)
|
||||
self.play_queue_popover.set_relative_to(self.play_queue_button)
|
||||
|
||||
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
play_queue_popover_header = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.popover_label = Gtk.Label(
|
||||
label='<b>Play Queue</b>',
|
||||
label="<b>Play Queue</b>",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=10,
|
||||
@@ -664,15 +645,15 @@ class PlayerControls(Gtk.ActionBar):
|
||||
play_queue_popover_header.add(self.popover_label)
|
||||
|
||||
load_play_queue = IconButton(
|
||||
'folder-download-symbolic', 'Load Queue from Server', margin=5)
|
||||
load_play_queue.set_action_name('app.update-play-queue-from-server')
|
||||
"folder-download-symbolic", "Load Queue from Server", margin=5
|
||||
)
|
||||
load_play_queue.set_action_name("app.update-play-queue-from-server")
|
||||
play_queue_popover_header.pack_end(load_play_queue, False, False, 0)
|
||||
|
||||
play_queue_popover_box.add(play_queue_popover_header)
|
||||
|
||||
play_queue_scrollbox = Gtk.ScrolledWindow(
|
||||
min_content_height=600,
|
||||
min_content_width=400,
|
||||
min_content_height=600, min_content_width=400,
|
||||
)
|
||||
|
||||
self.play_queue_store = Gtk.ListStore(
|
||||
@@ -682,12 +663,9 @@ class PlayerControls(Gtk.ActionBar):
|
||||
str, # song ID
|
||||
)
|
||||
self.play_queue_list = Gtk.TreeView(
|
||||
model=self.play_queue_store,
|
||||
reorderable=True,
|
||||
headers_visible=False,
|
||||
model=self.play_queue_store, reorderable=True, headers_visible=False,
|
||||
)
|
||||
self.play_queue_list.get_selection().set_mode(
|
||||
Gtk.SelectionMode.MULTIPLE)
|
||||
self.play_queue_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||
|
||||
# Album Art column.
|
||||
def filename_to_pixbuf(
|
||||
@@ -699,48 +677,42 @@ class PlayerControls(Gtk.ActionBar):
|
||||
):
|
||||
filename = model.get_value(iter, 0)
|
||||
if not filename:
|
||||
cell.set_property('icon_name', '')
|
||||
cell.set_property("icon_name", "")
|
||||
return
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
filename, 50, 50, True)
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
|
||||
|
||||
# If this is the playing song, then overlay the play icon.
|
||||
if model.get_value(iter, 2):
|
||||
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
|
||||
str(
|
||||
Path(__file__).parent.joinpath(
|
||||
'images/play-queue-play.png')))
|
||||
str(Path(__file__).parent.joinpath("images/play-queue-play.png"))
|
||||
)
|
||||
|
||||
play_overlay_pixbuf.composite(
|
||||
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1,
|
||||
GdkPixbuf.InterpType.NEAREST, 255)
|
||||
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 255
|
||||
)
|
||||
|
||||
cell.set_property('pixbuf', pixbuf)
|
||||
cell.set_property("pixbuf", pixbuf)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(55, 60)
|
||||
column = Gtk.TreeViewColumn('', renderer)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, filename_to_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererText(
|
||||
markup=True,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
)
|
||||
column = Gtk.TreeViewColumn('', renderer, markup=1)
|
||||
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END,)
|
||||
column = Gtk.TreeViewColumn("", renderer, markup=1)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
self.play_queue_list.connect('row-activated', self.on_song_activated)
|
||||
self.play_queue_list.connect("row-activated", self.on_song_activated)
|
||||
self.play_queue_list.connect(
|
||||
'button-press-event', self.on_play_queue_button_press)
|
||||
"button-press-event", self.on_play_queue_button_press
|
||||
)
|
||||
|
||||
# Set up drag-and-drop on the song list for editing the order of the
|
||||
# playlist.
|
||||
self.play_queue_store.connect(
|
||||
'row-inserted', self.on_play_queue_model_row_move)
|
||||
self.play_queue_store.connect(
|
||||
'row-deleted', self.on_play_queue_model_row_move)
|
||||
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
|
||||
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
|
||||
|
||||
play_queue_scrollbox.add(self.play_queue_list)
|
||||
play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0)
|
||||
@@ -749,16 +721,18 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
# Volume mute toggle
|
||||
self.volume_mute_toggle = IconButton(
|
||||
'audio-volume-high-symbolic', 'Toggle mute')
|
||||
self.volume_mute_toggle.set_action_name('app.mute-toggle')
|
||||
"audio-volume-high-symbolic", "Toggle mute"
|
||||
)
|
||||
self.volume_mute_toggle.set_action_name("app.mute-toggle")
|
||||
box.pack_start(self.volume_mute_toggle, False, True, 0)
|
||||
|
||||
# Volume slider
|
||||
self.volume_slider = Gtk.Scale.new_with_range(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5)
|
||||
self.volume_slider.set_name('volume-slider')
|
||||
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
|
||||
)
|
||||
self.volume_slider.set_name("volume-slider")
|
||||
self.volume_slider.set_draw_value(False)
|
||||
self.volume_slider.connect('value-changed', self.on_volume_change)
|
||||
self.volume_slider.connect("value-changed", self.on_volume_change)
|
||||
box.pack_start(self.volume_slider, True, True, 0)
|
||||
|
||||
vbox.pack_start(box, False, True, 0)
|
||||
|
@@ -3,7 +3,8 @@ from random import randint
|
||||
from typing import Any, Iterable, List, Tuple
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from fuzzywuzzy import process
|
||||
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
|
||||
|
||||
@@ -21,26 +22,27 @@ from sublime.ui.common import (
|
||||
|
||||
|
||||
class EditPlaylistDialog(EditFormDialog):
|
||||
entity_name: str = 'Playlist'
|
||||
entity_name: str = "Playlist"
|
||||
initial_size = (350, 120)
|
||||
text_fields = [('Name', 'name', False), ('Comment', 'comment', False)]
|
||||
boolean_fields = [('Public', 'public')]
|
||||
text_fields = [("Name", "name", False), ("Comment", "comment", False)]
|
||||
boolean_fields = [("Public", "public")]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
delete_playlist = Gtk.Button(label='Delete Playlist')
|
||||
delete_playlist = Gtk.Button(label="Delete Playlist")
|
||||
self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)]
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class PlaylistsPanel(Gtk.Paned):
|
||||
"""Defines the playlists panel."""
|
||||
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -55,12 +57,10 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
|
||||
self.playlist_detail_panel = PlaylistDetailPanel()
|
||||
self.playlist_detail_panel.connect(
|
||||
'song-clicked',
|
||||
lambda _, *args: self.emit('song-clicked', *args),
|
||||
"song-clicked", lambda _, *args: self.emit("song-clicked", *args),
|
||||
)
|
||||
self.playlist_detail_panel.connect(
|
||||
'refresh-window',
|
||||
lambda _, *args: self.emit('refresh-window', *args),
|
||||
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
|
||||
)
|
||||
self.pack2(self.playlist_detail_panel, True, False)
|
||||
|
||||
@@ -71,7 +71,7 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
|
||||
class PlaylistList(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -92,57 +92,50 @@ class PlaylistList(Gtk.Box):
|
||||
|
||||
playlist_list_actions = Gtk.ActionBar()
|
||||
|
||||
new_playlist_button = IconButton(
|
||||
'list-add-symbolic', label='New Playlist')
|
||||
new_playlist_button.connect('clicked', self.on_new_playlist_clicked)
|
||||
new_playlist_button = IconButton("list-add-symbolic", label="New Playlist")
|
||||
new_playlist_button.connect("clicked", self.on_new_playlist_clicked)
|
||||
playlist_list_actions.pack_start(new_playlist_button)
|
||||
|
||||
list_refresh_button = IconButton(
|
||||
'view-refresh-symbolic', 'Refresh list of playlists')
|
||||
list_refresh_button.connect('clicked', self.on_list_refresh_click)
|
||||
"view-refresh-symbolic", "Refresh list of playlists"
|
||||
)
|
||||
list_refresh_button.connect("clicked", self.on_list_refresh_click)
|
||||
playlist_list_actions.pack_end(list_refresh_button)
|
||||
|
||||
self.add(playlist_list_actions)
|
||||
|
||||
loading_new_playlist = Gtk.ListBox()
|
||||
|
||||
self.loading_indicator = Gtk.ListBoxRow(
|
||||
activatable=False,
|
||||
selectable=False,
|
||||
)
|
||||
loading_spinner = Gtk.Spinner(
|
||||
name='playlist-list-spinner', active=True)
|
||||
self.loading_indicator = Gtk.ListBoxRow(activatable=False, selectable=False,)
|
||||
loading_spinner = Gtk.Spinner(name="playlist-list-spinner", active=True)
|
||||
self.loading_indicator.add(loading_spinner)
|
||||
loading_new_playlist.add(self.loading_indicator)
|
||||
|
||||
self.new_playlist_row = Gtk.ListBoxRow(
|
||||
activatable=False, selectable=False)
|
||||
new_playlist_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, visible=False)
|
||||
self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False)
|
||||
new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False)
|
||||
|
||||
self.new_playlist_entry = Gtk.Entry(
|
||||
name='playlist-list-new-playlist-entry')
|
||||
self.new_playlist_entry.connect('activate', self.new_entry_activate)
|
||||
self.new_playlist_entry = Gtk.Entry(name="playlist-list-new-playlist-entry")
|
||||
self.new_playlist_entry.connect("activate", self.new_entry_activate)
|
||||
new_playlist_box.add(self.new_playlist_entry)
|
||||
|
||||
new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
confirm_button = IconButton(
|
||||
'object-select-symbolic',
|
||||
'Create playlist',
|
||||
name='playlist-list-new-playlist-confirm',
|
||||
"object-select-symbolic",
|
||||
"Create playlist",
|
||||
name="playlist-list-new-playlist-confirm",
|
||||
relief=True,
|
||||
)
|
||||
confirm_button.connect('clicked', self.confirm_button_clicked)
|
||||
confirm_button.connect("clicked", self.confirm_button_clicked)
|
||||
new_playlist_actions.pack_end(confirm_button, False, True, 0)
|
||||
|
||||
self.cancel_button = IconButton(
|
||||
'process-stop-symbolic',
|
||||
'Cancel create playlist',
|
||||
name='playlist-list-new-playlist-cancel',
|
||||
"process-stop-symbolic",
|
||||
"Cancel create playlist",
|
||||
name="playlist-list-new-playlist-cancel",
|
||||
relief=True,
|
||||
)
|
||||
self.cancel_button.connect('clicked', self.cancel_button_clicked)
|
||||
self.cancel_button.connect("clicked", self.cancel_button_clicked)
|
||||
new_playlist_actions.pack_end(self.cancel_button, False, True, 0)
|
||||
|
||||
new_playlist_box.add(new_playlist_actions)
|
||||
@@ -153,26 +146,26 @@ class PlaylistList(Gtk.Box):
|
||||
|
||||
list_scroll_window = Gtk.ScrolledWindow(min_content_width=220)
|
||||
|
||||
def create_playlist_row(
|
||||
model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
|
||||
def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
|
||||
row = Gtk.ListBoxRow(
|
||||
action_name='app.go-to-playlist',
|
||||
action_target=GLib.Variant('s', model.playlist_id),
|
||||
action_name="app.go-to-playlist",
|
||||
action_target=GLib.Variant("s", model.playlist_id),
|
||||
)
|
||||
row.add(
|
||||
Gtk.Label(
|
||||
label=f'<b>{model.name}</b>',
|
||||
label=f"<b>{model.name}</b>",
|
||||
use_markup=True,
|
||||
margin=10,
|
||||
halign=Gtk.Align.START,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
max_width_chars=30,
|
||||
))
|
||||
)
|
||||
)
|
||||
row.show_all()
|
||||
return row
|
||||
|
||||
self.playlists_store = Gio.ListStore()
|
||||
self.list = Gtk.ListBox(name='playlist-list-listbox')
|
||||
self.list = Gtk.ListBox(name="playlist-list-listbox")
|
||||
self.list.bind_model(self.playlists_store, create_playlist_row)
|
||||
list_scroll_window.add(self.list)
|
||||
self.pack_start(list_scroll_window, True, True, 0)
|
||||
@@ -196,12 +189,14 @@ class PlaylistList(Gtk.Box):
|
||||
new_store = []
|
||||
selected_idx = None
|
||||
for i, playlist in enumerate(playlists or []):
|
||||
if (app_config and app_config.state
|
||||
and app_config.state.selected_playlist_id == playlist.id):
|
||||
if (
|
||||
app_config
|
||||
and app_config.state
|
||||
and app_config.state.selected_playlist_id == playlist.id
|
||||
):
|
||||
selected_idx = i
|
||||
|
||||
new_store.append(
|
||||
PlaylistList.PlaylistModel(playlist.id, playlist.name))
|
||||
new_store.append(PlaylistList.PlaylistModel(playlist.id, playlist.name))
|
||||
|
||||
util.diff_model_store(self.playlists_store, new_store)
|
||||
|
||||
@@ -215,7 +210,7 @@ class PlaylistList(Gtk.Box):
|
||||
# Event Handlers
|
||||
# =========================================================================
|
||||
def on_new_playlist_clicked(self, _: Any):
|
||||
self.new_playlist_entry.set_text('Untitled Playlist')
|
||||
self.new_playlist_entry.set_text("Untitled Playlist")
|
||||
self.new_playlist_entry.grab_focus()
|
||||
self.new_playlist_row.show()
|
||||
|
||||
@@ -237,20 +232,20 @@ class PlaylistList(Gtk.Box):
|
||||
self.update(force=True)
|
||||
|
||||
self.loading_indicator.show()
|
||||
playlist_ceate_future = CacheManager.create_playlist(
|
||||
name=playlist_name)
|
||||
playlist_ceate_future = CacheManager.create_playlist(name=playlist_name)
|
||||
playlist_ceate_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(on_playlist_created, f))
|
||||
lambda f: GLib.idle_add(on_playlist_created, f)
|
||||
)
|
||||
|
||||
|
||||
class PlaylistDetailPanel(Gtk.Overlay):
|
||||
__gsignals__ = {
|
||||
'song-clicked': (
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
'refresh-window': (
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
@@ -263,19 +258,18 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
reordering_playlist_song_list: bool = False
|
||||
|
||||
def __init__(self):
|
||||
Gtk.Overlay.__init__(self, name='playlist-view-overlay')
|
||||
Gtk.Overlay.__init__(self, name="playlist-view-overlay")
|
||||
playlist_view_scroll_window = Gtk.ScrolledWindow()
|
||||
playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# Playlist info panel
|
||||
self.big_info_panel = Gtk.Box(
|
||||
name='playlist-info-panel',
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
name="playlist-info-panel", orientation=Gtk.Orientation.HORIZONTAL,
|
||||
)
|
||||
|
||||
self.playlist_artwork = SpinnerImage(
|
||||
image_name='playlist-album-artwork',
|
||||
spinner_name='playlist-artwork-spinner',
|
||||
image_name="playlist-album-artwork",
|
||||
spinner_name="playlist-artwork-spinner",
|
||||
image_size=200,
|
||||
)
|
||||
self.big_info_panel.pack_start(self.playlist_artwork, False, False, 0)
|
||||
@@ -285,65 +279,57 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
|
||||
# Action buttons (note we are packing end here, so we have to put them
|
||||
# in right-to-left).
|
||||
self.playlist_action_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.playlist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
view_refresh_button = IconButton(
|
||||
'view-refresh-symbolic', 'Refresh playlist info')
|
||||
view_refresh_button.connect('clicked', self.on_view_refresh_click)
|
||||
self.playlist_action_buttons.pack_end(
|
||||
view_refresh_button, False, False, 5)
|
||||
"view-refresh-symbolic", "Refresh playlist info"
|
||||
)
|
||||
view_refresh_button.connect("clicked", self.on_view_refresh_click)
|
||||
self.playlist_action_buttons.pack_end(view_refresh_button, False, False, 5)
|
||||
|
||||
playlist_edit_button = IconButton(
|
||||
'document-edit-symbolic', 'Edit paylist')
|
||||
playlist_edit_button.connect(
|
||||
'clicked', self.on_playlist_edit_button_click)
|
||||
self.playlist_action_buttons.pack_end(
|
||||
playlist_edit_button, False, False, 5)
|
||||
playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
|
||||
playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
|
||||
self.playlist_action_buttons.pack_end(playlist_edit_button, False, False, 5)
|
||||
|
||||
download_all_button = IconButton(
|
||||
'folder-download-symbolic', 'Download all songs in the playlist')
|
||||
"folder-download-symbolic", "Download all songs in the playlist"
|
||||
)
|
||||
download_all_button.connect(
|
||||
'clicked', self.on_playlist_list_download_all_button_click)
|
||||
self.playlist_action_buttons.pack_end(
|
||||
download_all_button, False, False, 5)
|
||||
"clicked", self.on_playlist_list_download_all_button_click
|
||||
)
|
||||
self.playlist_action_buttons.pack_end(download_all_button, False, False, 5)
|
||||
|
||||
playlist_details_box.pack_start(
|
||||
self.playlist_action_buttons, False, False, 5)
|
||||
playlist_details_box.pack_start(self.playlist_action_buttons, False, False, 5)
|
||||
|
||||
playlist_details_box.pack_start(Gtk.Box(), True, False, 0)
|
||||
|
||||
self.playlist_indicator = self.make_label(name='playlist-indicator')
|
||||
self.playlist_indicator = self.make_label(name="playlist-indicator")
|
||||
playlist_details_box.add(self.playlist_indicator)
|
||||
|
||||
self.playlist_name = self.make_label(name='playlist-name')
|
||||
self.playlist_name = self.make_label(name="playlist-name")
|
||||
playlist_details_box.add(self.playlist_name)
|
||||
|
||||
self.playlist_comment = self.make_label(name='playlist-comment')
|
||||
self.playlist_comment = self.make_label(name="playlist-comment")
|
||||
playlist_details_box.add(self.playlist_comment)
|
||||
|
||||
self.playlist_stats = self.make_label(name='playlist-stats')
|
||||
self.playlist_stats = self.make_label(name="playlist-stats")
|
||||
playlist_details_box.add(self.playlist_stats)
|
||||
|
||||
self.play_shuffle_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
name='playlist-play-shuffle-buttons',
|
||||
name="playlist-play-shuffle-buttons",
|
||||
)
|
||||
|
||||
play_button = IconButton(
|
||||
'media-playback-start-symbolic',
|
||||
label='Play All',
|
||||
relief=True,
|
||||
"media-playback-start-symbolic", label="Play All", relief=True,
|
||||
)
|
||||
play_button.connect('clicked', self.on_play_all_clicked)
|
||||
play_button.connect("clicked", self.on_play_all_clicked)
|
||||
self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
|
||||
|
||||
shuffle_button = IconButton(
|
||||
'media-playlist-shuffle-symbolic',
|
||||
label='Shuffle All',
|
||||
relief=True,
|
||||
"media-playlist-shuffle-symbolic", label="Shuffle All", relief=True,
|
||||
)
|
||||
shuffle_button.connect('clicked', self.on_shuffle_all_button)
|
||||
shuffle_button.connect("clicked", self.on_shuffle_all_button)
|
||||
self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
|
||||
|
||||
playlist_details_box.add(self.play_shuffle_buttons)
|
||||
@@ -371,17 +357,16 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
return max(row_score(key, row) for row in rows)
|
||||
|
||||
def playlist_song_list_search_fn(
|
||||
model: Gtk.ListStore,
|
||||
col: int,
|
||||
key: str,
|
||||
treeiter: Gtk.TreeIter,
|
||||
data: Any = None,
|
||||
model: Gtk.ListStore,
|
||||
col: int,
|
||||
key: str,
|
||||
treeiter: Gtk.TreeIter,
|
||||
data: Any = None,
|
||||
) -> bool:
|
||||
# TODO (#28): this is very inefficient, it's slow when the result
|
||||
# is close to the bottom of the list. Would be good to research
|
||||
# what the default one does (maybe it uses an index?).
|
||||
max_score = max_score_for_key(
|
||||
key, tuple(tuple(row[1:4]) for row in model))
|
||||
max_score = max_score_for_key(key, tuple(tuple(row[1:4]) for row in model))
|
||||
row_max_score = row_score(key, tuple(model[treeiter][1:4]))
|
||||
if row_max_score == max_score:
|
||||
return False # indicates match
|
||||
@@ -394,33 +379,31 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
enable_search=True,
|
||||
)
|
||||
self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn)
|
||||
self.playlist_songs.get_selection().set_mode(
|
||||
Gtk.SelectionMode.MULTIPLE)
|
||||
self.playlist_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||
|
||||
# Song status column.
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(30, 35)
|
||||
column = Gtk.TreeViewColumn('', renderer, icon_name=0)
|
||||
column = Gtk.TreeViewColumn("", renderer, icon_name=0)
|
||||
column.set_resizable(True)
|
||||
self.playlist_songs.append_column(column)
|
||||
|
||||
self.playlist_songs.append_column(SongListColumn("TITLE", 1, bold=True))
|
||||
self.playlist_songs.append_column(SongListColumn("ALBUM", 2))
|
||||
self.playlist_songs.append_column(SongListColumn("ARTIST", 3))
|
||||
self.playlist_songs.append_column(
|
||||
SongListColumn('TITLE', 1, bold=True))
|
||||
self.playlist_songs.append_column(SongListColumn('ALBUM', 2))
|
||||
self.playlist_songs.append_column(SongListColumn('ARTIST', 3))
|
||||
self.playlist_songs.append_column(
|
||||
SongListColumn('DURATION', 4, align=1, width=40))
|
||||
SongListColumn("DURATION", 4, align=1, width=40)
|
||||
)
|
||||
|
||||
self.playlist_songs.connect('row-activated', self.on_song_activated)
|
||||
self.playlist_songs.connect(
|
||||
'button-press-event', self.on_song_button_press)
|
||||
self.playlist_songs.connect("row-activated", self.on_song_activated)
|
||||
self.playlist_songs.connect("button-press-event", self.on_song_button_press)
|
||||
|
||||
# Set up drag-and-drop on the song list for editing the order of the
|
||||
# playlist.
|
||||
self.playlist_song_store.connect(
|
||||
'row-inserted', self.on_playlist_model_row_move)
|
||||
self.playlist_song_store.connect(
|
||||
'row-deleted', self.on_playlist_model_row_move)
|
||||
"row-inserted", self.on_playlist_model_row_move
|
||||
)
|
||||
self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
|
||||
|
||||
playlist_box.add(self.playlist_songs)
|
||||
|
||||
@@ -431,11 +414,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
playlist_view_spinner.start()
|
||||
|
||||
self.playlist_view_loading_box = Gtk.Alignment(
|
||||
name='playlist-view-overlay',
|
||||
xalign=0.5,
|
||||
yalign=0.5,
|
||||
xscale=0.1,
|
||||
yscale=0.1)
|
||||
name="playlist-view-overlay", xalign=0.5, yalign=0.5, xscale=0.1, yscale=0.1
|
||||
)
|
||||
self.playlist_view_loading_box.add(playlist_view_spinner)
|
||||
self.add_overlay(self.playlist_view_loading_box)
|
||||
|
||||
@@ -444,10 +424,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
if app_config.state.selected_playlist_id is None:
|
||||
self.playlist_artwork.set_from_file(None)
|
||||
self.playlist_indicator.set_markup('')
|
||||
self.playlist_name.set_markup('')
|
||||
self.playlist_indicator.set_markup("")
|
||||
self.playlist_name.set_markup("")
|
||||
self.playlist_comment.hide()
|
||||
self.playlist_stats.set_markup('')
|
||||
self.playlist_stats.set_markup("")
|
||||
self.playlist_action_buttons.hide()
|
||||
self.play_shuffle_buttons.hide()
|
||||
self.playlist_view_loading_box.hide()
|
||||
@@ -484,8 +464,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
self.playlist_id = playlist.id
|
||||
|
||||
# Update the info display.
|
||||
self.playlist_indicator.set_markup('PLAYLIST')
|
||||
self.playlist_name.set_markup(f'<b>{playlist.name}</b>')
|
||||
self.playlist_indicator.set_markup("PLAYLIST")
|
||||
self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
|
||||
if playlist.comment:
|
||||
self.playlist_comment.set_text(playlist.comment)
|
||||
self.playlist_comment.show()
|
||||
@@ -495,8 +475,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
|
||||
# Update the artwork.
|
||||
self.update_playlist_artwork(
|
||||
playlist.cover_art,
|
||||
order_token=order_token,
|
||||
playlist.cover_art, order_token=order_token,
|
||||
)
|
||||
|
||||
# Update the song list model. This requires some fancy diffing to
|
||||
@@ -505,14 +484,14 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
|
||||
new_store = [
|
||||
[
|
||||
util.get_cached_status_icon(
|
||||
CacheManager.get_cached_status(song)),
|
||||
util.get_cached_status_icon(CacheManager.get_cached_status(song)),
|
||||
song.title,
|
||||
song.album,
|
||||
song.artist,
|
||||
util.format_song_duration(song.duration),
|
||||
song.id,
|
||||
] for song in playlist.songs
|
||||
]
|
||||
for song in playlist.songs
|
||||
]
|
||||
|
||||
util.diff_song_store(self.playlist_song_store, new_store)
|
||||
@@ -551,7 +530,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
)
|
||||
|
||||
def on_playlist_edit_button_click(self, _: Any):
|
||||
playlist = CacheManager.get_playlist(self.playlist_id).result()
|
||||
assert self.playlist_id
|
||||
playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
|
||||
dialog = EditPlaylistDialog(self.get_toplevel(), playlist)
|
||||
playlist_deleted = False
|
||||
|
||||
@@ -561,9 +541,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
if result == Gtk.ResponseType.OK:
|
||||
CacheManager.update_playlist(
|
||||
self.playlist_id,
|
||||
name=dialog.data['name'].get_text(),
|
||||
comment=dialog.data['comment'].get_text(),
|
||||
public=dialog.data['public'].get_active(),
|
||||
name=dialog.data["name"].get_text(),
|
||||
comment=dialog.data["comment"].get_text(),
|
||||
public=dialog.data["public"].get_active(),
|
||||
)
|
||||
elif result == Gtk.ResponseType.NO:
|
||||
# Delete the playlist.
|
||||
@@ -571,7 +551,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
transient_for=self.get_toplevel(),
|
||||
message_type=Gtk.MessageType.WARNING,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
text='Confirm deletion',
|
||||
text="Confirm deletion",
|
||||
)
|
||||
confirm_dialog.add_buttons(
|
||||
Gtk.STOCK_DELETE,
|
||||
@@ -580,8 +560,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
Gtk.ResponseType.CANCEL,
|
||||
)
|
||||
confirm_dialog.format_secondary_markup(
|
||||
'Are you sure you want to delete the '
|
||||
f'"{playlist.name}" playlist?')
|
||||
"Are you sure you want to delete the "
|
||||
f'"{playlist.name}" playlist?'
|
||||
)
|
||||
result = confirm_dialog.run()
|
||||
confirm_dialog.destroy()
|
||||
if result == Gtk.ResponseType.YES:
|
||||
@@ -597,10 +578,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
CacheManager.delete_cached_cover_art(self.playlist_id)
|
||||
CacheManager.invalidate_playlists_cache()
|
||||
self.emit(
|
||||
'refresh-window',
|
||||
"refresh-window",
|
||||
{
|
||||
'selected_playlist_id':
|
||||
None if playlist_deleted else self.playlist_id
|
||||
"selected_playlist_id": None
|
||||
if playlist_deleted
|
||||
else self.playlist_id
|
||||
},
|
||||
True,
|
||||
)
|
||||
@@ -611,9 +593,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
def download_state_change(*args):
|
||||
GLib.idle_add(
|
||||
lambda: self.update_playlist_view(
|
||||
self.playlist_id,
|
||||
order_token=self.update_playlist_view_order_token,
|
||||
))
|
||||
self.playlist_id, order_token=self.update_playlist_view_order_token,
|
||||
)
|
||||
)
|
||||
|
||||
song_ids = [s[-1] for s in self.playlist_song_store]
|
||||
CacheManager.batch_download_songs(
|
||||
@@ -624,43 +606,30 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
|
||||
def on_play_all_clicked(self, _: Any):
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
"song-clicked",
|
||||
0,
|
||||
[m[-1] for m in self.playlist_song_store],
|
||||
{
|
||||
'force_shuffle_state': False,
|
||||
'active_playlist_id': self.playlist_id,
|
||||
},
|
||||
{"force_shuffle_state": False, "active_playlist_id": self.playlist_id,},
|
||||
)
|
||||
|
||||
def on_shuffle_all_button(self, _: Any):
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
randint(0,
|
||||
len(self.playlist_song_store) - 1),
|
||||
"song-clicked",
|
||||
randint(0, len(self.playlist_song_store) - 1),
|
||||
[m[-1] for m in self.playlist_song_store],
|
||||
{
|
||||
'force_shuffle_state': True,
|
||||
'active_playlist_id': self.playlist_id,
|
||||
},
|
||||
{"force_shuffle_state": True, "active_playlist_id": self.playlist_id,},
|
||||
)
|
||||
|
||||
def on_song_activated(self, _: Any, idx: Gtk.TreePath, col: Any):
|
||||
# The song ID is in the last column of the model.
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
"song-clicked",
|
||||
idx.get_indices()[0],
|
||||
[m[-1] for m in self.playlist_song_store],
|
||||
{
|
||||
'active_playlist_id': self.playlist_id,
|
||||
},
|
||||
{"active_playlist_id": self.playlist_id,},
|
||||
)
|
||||
|
||||
def on_song_button_press(
|
||||
self,
|
||||
tree: Gtk.TreeView,
|
||||
event: Gdk.EventButton,
|
||||
) -> bool:
|
||||
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton,) -> bool:
|
||||
if event.button == 3: # Right click
|
||||
clicked_path = tree.get_path_at_pos(event.x, event.y)
|
||||
if not clicked_path:
|
||||
@@ -674,7 +643,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
lambda: self.update_playlist_view(
|
||||
self.playlist_id,
|
||||
order_token=self.update_playlist_view_order_token,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
# Use the new selection instead of the old one for calculating what
|
||||
# to do the right click on.
|
||||
@@ -685,10 +655,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
song_ids = [self.playlist_song_store[p][-1] for p in paths]
|
||||
|
||||
# Used to adjust for the header row.
|
||||
bin_coords = tree.convert_tree_to_bin_window_coords(
|
||||
event.x, event.y)
|
||||
widget_coords = tree.convert_tree_to_widget_coords(
|
||||
event.x, event.y)
|
||||
bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
|
||||
widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
|
||||
|
||||
def on_remove_songs_click(_: Any):
|
||||
CacheManager.update_playlist(
|
||||
@@ -702,8 +670,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
)
|
||||
|
||||
remove_text = (
|
||||
'Remove ' + util.pluralize('song', len(song_ids))
|
||||
+ ' from playlist')
|
||||
"Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
|
||||
)
|
||||
util.show_song_popover(
|
||||
song_ids,
|
||||
event.x,
|
||||
@@ -741,25 +709,12 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
self.playlist_artwork.set_loading(True)
|
||||
self.playlist_view_loading_box.show_all()
|
||||
|
||||
def make_label(
|
||||
self,
|
||||
text: str = None,
|
||||
name: str = None,
|
||||
**params,
|
||||
) -> Gtk.Label:
|
||||
return Gtk.Label(
|
||||
label=text,
|
||||
name=name,
|
||||
halign=Gtk.Align.START,
|
||||
**params,
|
||||
)
|
||||
def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label:
|
||||
return Gtk.Label(label=text, name=name, halign=Gtk.Align.START, **params,)
|
||||
|
||||
@util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k))
|
||||
@util.async_callback(lambda *a, **k: AdapterManager.get_playlist_details(*a, **k),)
|
||||
def _update_playlist_order(
|
||||
self,
|
||||
playlist: PlaylistDetails,
|
||||
app_config: AppConfiguration,
|
||||
**kwargs,
|
||||
self, playlist: PlaylistDetails, app_config: AppConfiguration, **kwargs,
|
||||
):
|
||||
self.playlist_view_loading_box.show_all()
|
||||
update_playlist_future = CacheManager.update_playlist(
|
||||
@@ -774,20 +729,22 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
playlist.id,
|
||||
force=True,
|
||||
order_token=self.update_playlist_view_order_token,
|
||||
)))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def _format_stats(self, playlist: PlaylistDetails) -> str:
|
||||
created_date = playlist.created.strftime('%B %d, %Y')
|
||||
created_date = playlist.created.strftime("%B %d, %Y")
|
||||
lines = [
|
||||
util.dot_join(
|
||||
f'Created by {playlist.owner} on {created_date}',
|
||||
f"Created by {playlist.owner} on {created_date}",
|
||||
f"{'Not v' if not playlist.public else 'V'}isible to others",
|
||||
),
|
||||
util.dot_join(
|
||||
'{} {}'.format(
|
||||
playlist.song_count,
|
||||
util.pluralize("song", playlist.song_count)),
|
||||
"{} {}".format(
|
||||
playlist.song_count, util.pluralize("song", playlist.song_count)
|
||||
),
|
||||
util.format_sequence_duration(playlist.duration),
|
||||
),
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
return "\n".join(lines)
|
||||
|
@@ -1,53 +1,51 @@
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
from .common.edit_form_dialog import EditFormDialog
|
||||
|
||||
|
||||
class SettingsDialog(EditFormDialog):
|
||||
title: str = 'Settings'
|
||||
title: str = "Settings"
|
||||
initial_size = (450, 250)
|
||||
text_fields = [
|
||||
(
|
||||
'Port Number (for streaming to Chromecasts on the LAN) *',
|
||||
'port_number',
|
||||
"Port Number (for streaming to Chromecasts on the LAN) *",
|
||||
"port_number",
|
||||
False,
|
||||
),
|
||||
]
|
||||
boolean_fields = [
|
||||
('Always stream songs', 'always_stream'),
|
||||
('When streaming, also download song', 'download_on_stream'),
|
||||
("Always stream songs", "always_stream"),
|
||||
("When streaming, also download song", "download_on_stream"),
|
||||
("Show a notification when a song begins to play", "song_play_notification"),
|
||||
(
|
||||
'Show a notification when a song begins to play',
|
||||
'song_play_notification',
|
||||
),
|
||||
(
|
||||
'Serve locally cached files over the LAN to Chromecast devices. *',
|
||||
'serve_over_lan',
|
||||
"Serve locally cached files over the LAN to Chromecast devices. *",
|
||||
"serve_over_lan",
|
||||
),
|
||||
]
|
||||
numeric_fields = [
|
||||
(
|
||||
'How many songs in the play queue do you want to prefetch?',
|
||||
'prefetch_amount',
|
||||
"How many songs in the play queue do you want to prefetch?",
|
||||
"prefetch_amount",
|
||||
(0, 10, 1),
|
||||
0,
|
||||
),
|
||||
(
|
||||
'How many song downloads do you want to allow concurrently?',
|
||||
'concurrent_download_limit',
|
||||
"How many song downloads do you want to allow concurrently?",
|
||||
"concurrent_download_limit",
|
||||
(1, 10, 1),
|
||||
5,
|
||||
),
|
||||
]
|
||||
option_fields = [
|
||||
('Replay Gain', 'replay_gain', ('Disabled', 'Track', 'Album')),
|
||||
("Replay Gain", "replay_gain", ("Disabled", "Track", "Album")),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.extra_label = Gtk.Label(
|
||||
label='<i>* Will be appplied after restarting Sublime Music</i>',
|
||||
label="<i>* Will be appplied after restarting Sublime Music</i>",
|
||||
justify=Gtk.Justification.LEFT,
|
||||
use_markup=True,
|
||||
)
|
||||
|
@@ -12,50 +12,48 @@ class RepeatType(Enum):
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
icon_name = [
|
||||
'repeat-symbolic',
|
||||
'repeat-symbolic',
|
||||
'repeat-song-symbolic',
|
||||
][self.value]
|
||||
return f'media-playlist-{icon_name}'
|
||||
icon_name = ["repeat-symbolic", "repeat-symbolic", "repeat-song-symbolic"][
|
||||
self.value
|
||||
]
|
||||
return f"media-playlist-{icon_name}"
|
||||
|
||||
def as_mpris_loop_status(self) -> str:
|
||||
return ['None', 'Playlist', 'Track'][self.value]
|
||||
return ["None", "Playlist", "Track"][self.value]
|
||||
|
||||
@staticmethod
|
||||
def from_mpris_loop_status(loop_status: str) -> 'RepeatType':
|
||||
def from_mpris_loop_status(loop_status: str) -> "RepeatType":
|
||||
return {
|
||||
'None': RepeatType.NO_REPEAT,
|
||||
'Track': RepeatType.REPEAT_SONG,
|
||||
'Playlist': RepeatType.REPEAT_QUEUE,
|
||||
"None": RepeatType.NO_REPEAT,
|
||||
"Track": RepeatType.REPEAT_SONG,
|
||||
"Playlist": RepeatType.REPEAT_QUEUE,
|
||||
}[loop_status]
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIState:
|
||||
"""Represents the UI state of the application."""
|
||||
|
||||
version: int = 1
|
||||
playing: bool = False
|
||||
current_song_index: int = -1
|
||||
play_queue: List[str] = field(default_factory=list)
|
||||
old_play_queue: List[str] = field(default_factory=list)
|
||||
_volume: Dict[str, float] = field(
|
||||
default_factory=lambda: {'this device': 100.0})
|
||||
_volume: Dict[str, float] = field(default_factory=lambda: {"this device": 100.0})
|
||||
is_muted: bool = False
|
||||
repeat_type: RepeatType = RepeatType.NO_REPEAT
|
||||
shuffle_on: bool = False
|
||||
song_progress: float = 0
|
||||
current_device: str = 'this device'
|
||||
current_tab: str = 'albums'
|
||||
current_device: str = "this device"
|
||||
current_tab: str = "albums"
|
||||
selected_album_id: Optional[str] = None
|
||||
selected_artist_id: Optional[str] = None
|
||||
selected_browse_element_id: Optional[str] = None
|
||||
selected_playlist_id: Optional[str] = None
|
||||
|
||||
# State for Album sort.
|
||||
current_album_sort: str = 'random'
|
||||
current_album_genre: str = 'Rock'
|
||||
current_album_alphabetical_sort: str = 'name'
|
||||
current_album_sort: str = "random"
|
||||
current_album_genre: str = "Rock"
|
||||
current_album_alphabetical_sort: str = "name"
|
||||
current_album_from_year: int = 2010
|
||||
current_album_to_year: int = 2020
|
||||
|
||||
@@ -67,8 +65,12 @@ class UIState:
|
||||
@property
|
||||
def current_song(self) -> Optional[Child]:
|
||||
from sublime.cache_manager import CacheManager
|
||||
if (not self.play_queue or self.current_song_index < 0
|
||||
or not CacheManager.ready()):
|
||||
|
||||
if (
|
||||
not self.play_queue
|
||||
or self.current_song_index < 0
|
||||
or not CacheManager.ready()
|
||||
):
|
||||
return None
|
||||
|
||||
current_song_id = self.play_queue[self.current_song_index]
|
||||
|
@@ -16,12 +16,14 @@ from typing import (
|
||||
|
||||
import gi
|
||||
from deepdiff import DeepDiff
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
from sublime.adapters import AdapterManager
|
||||
from sublime.adapters.api_objects import Playlist
|
||||
from sublime.cache_manager import CacheManager, SongCacheStatus
|
||||
from sublime.config import AppConfiguration
|
||||
from sublime.server.api_objects import Playlist
|
||||
|
||||
|
||||
def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str:
|
||||
@@ -37,16 +39,12 @@ def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str:
|
||||
if isinstance(duration_secs, timedelta):
|
||||
duration_secs = round(duration_secs.total_seconds())
|
||||
if not duration_secs:
|
||||
return '-:--'
|
||||
return "-:--"
|
||||
|
||||
return f'{duration_secs // 60}:{duration_secs % 60:02}'
|
||||
return f"{duration_secs // 60}:{duration_secs % 60:02}"
|
||||
|
||||
|
||||
def pluralize(
|
||||
string: str,
|
||||
number: int,
|
||||
pluralized_form: str = None,
|
||||
) -> str:
|
||||
def pluralize(string: str, number: int, pluralized_form: str = None,) -> str:
|
||||
"""
|
||||
Pluralize the given string given the count as a number.
|
||||
|
||||
@@ -58,7 +56,7 @@ def pluralize(
|
||||
'foos'
|
||||
"""
|
||||
if number != 1:
|
||||
return pluralized_form or f'{string}s'
|
||||
return pluralized_form or f"{string}s"
|
||||
return string
|
||||
|
||||
|
||||
@@ -82,48 +80,46 @@ def format_sequence_duration(duration_secs: Union[int, timedelta]) -> str:
|
||||
|
||||
format_components = []
|
||||
if duration_hrs > 0:
|
||||
hrs = '{} {}'.format(duration_hrs, pluralize('hour', duration_hrs))
|
||||
hrs = "{} {}".format(duration_hrs, pluralize("hour", duration_hrs))
|
||||
format_components.append(hrs)
|
||||
|
||||
if duration_mins > 0:
|
||||
mins = '{} {}'.format(
|
||||
duration_mins, pluralize('minute', duration_mins))
|
||||
mins = "{} {}".format(duration_mins, pluralize("minute", duration_mins))
|
||||
format_components.append(mins)
|
||||
|
||||
# Show seconds if there are no hours.
|
||||
if duration_hrs == 0:
|
||||
secs = '{} {}'.format(
|
||||
duration_secs, pluralize('second', duration_secs))
|
||||
secs = "{} {}".format(duration_secs, pluralize("second", duration_secs))
|
||||
format_components.append(secs)
|
||||
|
||||
return ', '.join(format_components)
|
||||
return ", ".join(format_components)
|
||||
|
||||
|
||||
def esc(string: Optional[str]) -> str:
|
||||
if string is None:
|
||||
return ''
|
||||
return string.replace('&', '&').replace(" target='_blank'", '')
|
||||
return ""
|
||||
return string.replace("&", "&").replace(" target='_blank'", "")
|
||||
|
||||
|
||||
def dot_join(*items: Any) -> str:
|
||||
"""
|
||||
Joins the given strings with a dot character. Filters out None values.
|
||||
"""
|
||||
return ' • '.join(map(str, filter(lambda x: x is not None, items)))
|
||||
return " • ".join(map(str, filter(lambda x: x is not None, items)))
|
||||
|
||||
|
||||
def get_cached_status_icon(cache_status: SongCacheStatus) -> str:
|
||||
cache_icon = {
|
||||
SongCacheStatus.NOT_CACHED: '',
|
||||
SongCacheStatus.CACHED: 'folder-download-symbolic',
|
||||
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
|
||||
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
|
||||
SongCacheStatus.NOT_CACHED: "",
|
||||
SongCacheStatus.CACHED: "folder-download-symbolic",
|
||||
SongCacheStatus.PERMANENTLY_CACHED: "view-pin-symbolic",
|
||||
SongCacheStatus.DOWNLOADING: "emblem-synchronizing-symbolic",
|
||||
}
|
||||
return cache_icon[cache_status]
|
||||
|
||||
|
||||
def _parse_diff_location(location: str) -> Tuple:
|
||||
match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location)
|
||||
match = re.match(r"root\[(\d*)\](?:\[(\d*)\]|\.(.*))?", location)
|
||||
return tuple(g for g in cast(Match, match).groups() if g is not None)
|
||||
|
||||
|
||||
@@ -136,18 +132,18 @@ def diff_song_store(store_to_edit: Any, new_store: Iterable[Any]):
|
||||
|
||||
# Diff the lists to determine what needs to be changed.
|
||||
diff = DeepDiff(old_store, new_store)
|
||||
changed = diff.get('values_changed', {})
|
||||
added = diff.get('iterable_item_added', {})
|
||||
removed = diff.get('iterable_item_removed', {})
|
||||
changed = diff.get("values_changed", {})
|
||||
added = diff.get("iterable_item_added", {})
|
||||
removed = diff.get("iterable_item_removed", {})
|
||||
|
||||
for edit_location, diff in changed.items():
|
||||
idx, field = _parse_diff_location(edit_location)
|
||||
store_to_edit[int(idx)][int(field)] = diff['new_value']
|
||||
store_to_edit[int(idx)][int(field)] = diff["new_value"]
|
||||
|
||||
for add_location, value in added.items():
|
||||
for _, value in added.items():
|
||||
store_to_edit.append(value)
|
||||
|
||||
for remove_location, value in reversed(list(removed.items())):
|
||||
for remove_location, _ in reversed(list(removed.items())):
|
||||
remove_at = int(_parse_diff_location(remove_location)[0])
|
||||
del store_to_edit[remove_at]
|
||||
|
||||
@@ -176,7 +172,7 @@ def show_song_popover(
|
||||
position: Gtk.PositionType = Gtk.PositionType.BOTTOM,
|
||||
on_download_state_change: Callable[[], None] = lambda: None,
|
||||
show_remove_from_playlist_button: bool = False,
|
||||
extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = [],
|
||||
extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = None,
|
||||
):
|
||||
def on_download_songs_click(_: Any):
|
||||
CacheManager.batch_download_songs(
|
||||
@@ -187,8 +183,7 @@ def show_song_popover(
|
||||
|
||||
def on_remove_downloads_click(_: Any):
|
||||
CacheManager.batch_delete_cached_songs(
|
||||
song_ids,
|
||||
on_song_delete=on_download_state_change,
|
||||
song_ids, on_song_delete=on_download_state_change,
|
||||
)
|
||||
|
||||
def on_add_to_playlist_click(_: Any, playlist: Playlist):
|
||||
@@ -217,41 +212,43 @@ def show_song_popover(
|
||||
if download_sensitive or status == SongCacheStatus.NOT_CACHED:
|
||||
download_sensitive = True
|
||||
|
||||
if (remove_download_sensitive
|
||||
or status in (SongCacheStatus.CACHED,
|
||||
SongCacheStatus.PERMANENTLY_CACHED)):
|
||||
if remove_download_sensitive or status in (
|
||||
SongCacheStatus.CACHED,
|
||||
SongCacheStatus.PERMANENTLY_CACHED,
|
||||
):
|
||||
remove_download_sensitive = True
|
||||
|
||||
go_to_album_button = Gtk.ModelButton(
|
||||
text='Go to album', action_name='app.go-to-album')
|
||||
text="Go to album", action_name="app.go-to-album"
|
||||
)
|
||||
if len(albums) == 1 and list(albums)[0] is not None:
|
||||
album_value = GLib.Variant('s', list(albums)[0])
|
||||
album_value = GLib.Variant("s", list(albums)[0])
|
||||
go_to_album_button.set_action_target_value(album_value)
|
||||
|
||||
go_to_artist_button = Gtk.ModelButton(
|
||||
text='Go to artist', action_name='app.go-to-artist')
|
||||
text="Go to artist", action_name="app.go-to-artist"
|
||||
)
|
||||
if len(artists) == 1 and list(artists)[0] is not None:
|
||||
artist_value = GLib.Variant('s', list(artists)[0])
|
||||
artist_value = GLib.Variant("s", list(artists)[0])
|
||||
go_to_artist_button.set_action_target_value(artist_value)
|
||||
|
||||
browse_to_song = Gtk.ModelButton(
|
||||
text=f"Browse to {pluralize('song', song_count)}",
|
||||
action_name='app.browse-to',
|
||||
text=f"Browse to {pluralize('song', song_count)}", action_name="app.browse-to",
|
||||
)
|
||||
if len(parents) == 1 and list(parents)[0] is not None:
|
||||
parent_value = GLib.Variant('s', list(parents)[0])
|
||||
parent_value = GLib.Variant("s", list(parents)[0])
|
||||
browse_to_song.set_action_target_value(parent_value)
|
||||
|
||||
menu_items = [
|
||||
Gtk.ModelButton(
|
||||
text='Play next',
|
||||
action_name='app.play-next',
|
||||
action_target=GLib.Variant('as', song_ids),
|
||||
text="Play next",
|
||||
action_name="app.play-next",
|
||||
action_target=GLib.Variant("as", song_ids),
|
||||
),
|
||||
Gtk.ModelButton(
|
||||
text='Add to queue',
|
||||
action_name='app.add-to-queue',
|
||||
action_target=GLib.Variant('as', song_ids),
|
||||
text="Add to queue",
|
||||
action_name="app.add-to-queue",
|
||||
action_target=GLib.Variant("as", song_ids),
|
||||
),
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
|
||||
go_to_album_button,
|
||||
@@ -275,19 +272,19 @@ def show_song_popover(
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
|
||||
Gtk.ModelButton(
|
||||
text=f"Add {pluralize('song', song_count)} to playlist",
|
||||
menu_name='add-to-playlist',
|
||||
menu_name="add-to-playlist",
|
||||
),
|
||||
*extra_menu_items,
|
||||
*(extra_menu_items or []),
|
||||
]
|
||||
|
||||
for item in menu_items:
|
||||
if type(item) == tuple:
|
||||
el, fn = item
|
||||
el.connect('clicked', fn)
|
||||
el.get_style_context().add_class('menu-button')
|
||||
el.connect("clicked", fn)
|
||||
el.get_style_context().add_class("menu-button")
|
||||
vbox.pack_start(item[0], False, True, 0)
|
||||
else:
|
||||
item.get_style_context().add_class('menu-button')
|
||||
item.get_style_context().add_class("menu-button")
|
||||
vbox.pack_start(item, False, True, 0)
|
||||
|
||||
popover.add(vbox)
|
||||
@@ -296,22 +293,18 @@ def show_song_popover(
|
||||
playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# Back button
|
||||
playlists_vbox.add(
|
||||
Gtk.ModelButton(
|
||||
inverted=True,
|
||||
centered=True,
|
||||
menu_name='main',
|
||||
))
|
||||
playlists_vbox.add(Gtk.ModelButton(inverted=True, centered=True, menu_name="main",))
|
||||
|
||||
# The playlist buttons
|
||||
for playlist in CacheManager.get_playlists().result():
|
||||
# TODO lazy load
|
||||
for playlist in AdapterManager.get_playlists().result():
|
||||
button = Gtk.ModelButton(text=playlist.name)
|
||||
button.get_style_context().add_class('menu-button')
|
||||
button.connect('clicked', on_add_to_playlist_click, playlist)
|
||||
button.get_style_context().add_class("menu-button")
|
||||
button.connect("clicked", on_add_to_playlist_click, playlist)
|
||||
playlists_vbox.pack_start(button, False, True, 0)
|
||||
|
||||
popover.add(playlists_vbox)
|
||||
popover.child_set_property(playlists_vbox, 'submenu', 'add-to-playlist')
|
||||
popover.child_set_property(playlists_vbox, "submenu", "add-to-playlist")
|
||||
|
||||
# Positioning of the popover.
|
||||
rect = Gdk.Rectangle()
|
||||
@@ -340,6 +333,7 @@ def async_callback(
|
||||
:param future_fn: a function which generates a
|
||||
:class:`concurrent.futures.Future` or :class:`CacheManager.Result`.
|
||||
"""
|
||||
|
||||
def decorator(callback_fn: Callable) -> Callable:
|
||||
@functools.wraps(callback_fn)
|
||||
def wrapper(
|
||||
@@ -351,10 +345,9 @@ def async_callback(
|
||||
**kwargs,
|
||||
):
|
||||
if before_download:
|
||||
on_before_download = (
|
||||
lambda: GLib.idle_add(before_download, self))
|
||||
on_before_download = lambda: GLib.idle_add(before_download, self)
|
||||
else:
|
||||
on_before_download = (lambda: None)
|
||||
on_before_download = lambda: None
|
||||
|
||||
def future_callback(f: Union[Future, CacheManager.Result]):
|
||||
try:
|
||||
@@ -371,13 +364,11 @@ def async_callback(
|
||||
app_config=app_config,
|
||||
force=force,
|
||||
order_token=order_token,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
future: Union[Future, CacheManager.Result] = future_fn(
|
||||
*args,
|
||||
before_download=on_before_download,
|
||||
force=force,
|
||||
**kwargs,
|
||||
*args, before_download=on_before_download, force=force, **kwargs,
|
||||
)
|
||||
future.add_done_callback(future_callback)
|
||||
|
||||
|
@@ -9,7 +9,7 @@ from sublime.adapters import CacheMissError
|
||||
from sublime.adapters.filesystem import FilesystemAdapter
|
||||
from sublime.adapters.subsonic import api_objects as SubsonicAPI
|
||||
|
||||
MOCK_DATA_FILES = Path(__file__).parent.joinpath('mock_data')
|
||||
MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -27,15 +27,13 @@ def cache_adapter(tmp_path: Path):
|
||||
|
||||
|
||||
def mock_data_files(
|
||||
request_name: str,
|
||||
mode: str = 'r',
|
||||
request_name: str, mode: str = "r",
|
||||
) -> Generator[Tuple[Path, Any], None, None]:
|
||||
"""
|
||||
Yields all of the files in the mock_data directory that start with
|
||||
``request_name``.
|
||||
Yields all of the files in the mock_data directory that start with ``request_name``.
|
||||
"""
|
||||
for file in MOCK_DATA_FILES.iterdir():
|
||||
if file.name.split('-')[0] in request_name:
|
||||
if file.name.split("-")[0] in request_name:
|
||||
with open(file, mode) as f:
|
||||
yield file, f.read()
|
||||
|
||||
@@ -45,8 +43,7 @@ def test_caching_get_playlists(cache_adapter: FilesystemAdapter):
|
||||
cache_adapter.get_playlists()
|
||||
|
||||
# Ingest an empty list (for example, no playlists added yet to server).
|
||||
cache_adapter.ingest_new_data(
|
||||
FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), [])
|
||||
cache_adapter.ingest_new_data(FilesystemAdapter.FunctionNames.GET_PLAYLISTS, (), [])
|
||||
|
||||
# After the first cache miss of get_playlists, even if an empty list is
|
||||
# returned, the next one should not be a cache miss.
|
||||
@@ -57,16 +54,19 @@ def test_caching_get_playlists(cache_adapter: FilesystemAdapter):
|
||||
FilesystemAdapter.FunctionNames.GET_PLAYLISTS,
|
||||
(),
|
||||
[
|
||||
SubsonicAPI.Playlist('1', 'test1', comment='comment'),
|
||||
SubsonicAPI.Playlist('2', 'test2'),
|
||||
SubsonicAPI.Playlist("1", "test1", comment="comment"),
|
||||
SubsonicAPI.Playlist("2", "test2"),
|
||||
],
|
||||
)
|
||||
|
||||
playlists = cache_adapter.get_playlists()
|
||||
assert len(playlists) == 2
|
||||
assert (playlists[0].id, playlists[0].name,
|
||||
playlists[0].comment) == ('1', 'test1', 'comment')
|
||||
assert (playlists[1].id, playlists[1].name) == ('2', 'test2')
|
||||
assert (playlists[0].id, playlists[0].name, playlists[0].comment) == (
|
||||
"1",
|
||||
"test1",
|
||||
"comment",
|
||||
)
|
||||
assert (playlists[1].id, playlists[1].name) == ("2", "test2")
|
||||
|
||||
|
||||
def test_no_caching_get_playlists(adapter: FilesystemAdapter):
|
||||
@@ -82,38 +82,38 @@ def test_no_caching_get_playlists(adapter: FilesystemAdapter):
|
||||
|
||||
def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
|
||||
with pytest.raises(CacheMissError):
|
||||
cache_adapter.get_playlist_details('1')
|
||||
cache_adapter.get_playlist_details("1")
|
||||
|
||||
# Simulate the playlist being retrieved from Subsonic.
|
||||
songs = [
|
||||
SubsonicAPI.Song(
|
||||
'2',
|
||||
'Song 2',
|
||||
parent='foo',
|
||||
album='foo',
|
||||
artist='foo',
|
||||
"2",
|
||||
"Song 2",
|
||||
parent="foo",
|
||||
album="foo",
|
||||
artist="foo",
|
||||
duration=timedelta(seconds=20.8),
|
||||
path='/foo/song2.mp3',
|
||||
path="/foo/song2.mp3",
|
||||
),
|
||||
SubsonicAPI.Song(
|
||||
'1',
|
||||
'Song 1',
|
||||
parent='foo',
|
||||
album='foo',
|
||||
artist='foo',
|
||||
"1",
|
||||
"Song 1",
|
||||
parent="foo",
|
||||
album="foo",
|
||||
artist="foo",
|
||||
duration=timedelta(seconds=10.2),
|
||||
path='/foo/song1.mp3',
|
||||
path="/foo/song1.mp3",
|
||||
),
|
||||
]
|
||||
cache_adapter.ingest_new_data(
|
||||
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
|
||||
('1', ),
|
||||
SubsonicAPI.PlaylistWithSongs('1', 'test1', songs=songs),
|
||||
("1",),
|
||||
SubsonicAPI.PlaylistWithSongs("1", "test1", songs=songs),
|
||||
)
|
||||
|
||||
playlist = cache_adapter.get_playlist_details('1')
|
||||
assert playlist.id == '1'
|
||||
assert playlist.name == 'test1'
|
||||
playlist = cache_adapter.get_playlist_details("1")
|
||||
assert playlist.id == "1"
|
||||
assert playlist.name == "test1"
|
||||
assert playlist.song_count == 2
|
||||
assert playlist.duration == timedelta(seconds=31)
|
||||
for actual, song in zip(playlist.songs, songs):
|
||||
@@ -123,42 +123,42 @@ def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
|
||||
# "Force refresh" the playlist
|
||||
songs = [
|
||||
SubsonicAPI.Song(
|
||||
'3',
|
||||
'Song 3',
|
||||
parent='foo',
|
||||
album='foo',
|
||||
artist='foo',
|
||||
"3",
|
||||
"Song 3",
|
||||
parent="foo",
|
||||
album="foo",
|
||||
artist="foo",
|
||||
duration=timedelta(seconds=10.2),
|
||||
path='/foo/song3.mp3',
|
||||
path="/foo/song3.mp3",
|
||||
),
|
||||
SubsonicAPI.Song(
|
||||
'1',
|
||||
'Song 1',
|
||||
parent='foo',
|
||||
album='foo',
|
||||
artist='foo',
|
||||
"1",
|
||||
"Song 1",
|
||||
parent="foo",
|
||||
album="foo",
|
||||
artist="foo",
|
||||
duration=timedelta(seconds=21.8),
|
||||
path='/foo/song1.mp3',
|
||||
path="/foo/song1.mp3",
|
||||
),
|
||||
SubsonicAPI.Song(
|
||||
'1',
|
||||
'Song 1',
|
||||
parent='foo',
|
||||
album='foo',
|
||||
artist='foo',
|
||||
"1",
|
||||
"Song 1",
|
||||
parent="foo",
|
||||
album="foo",
|
||||
artist="foo",
|
||||
duration=timedelta(seconds=21.8),
|
||||
path='/foo/song1.mp3',
|
||||
path="/foo/song1.mp3",
|
||||
),
|
||||
]
|
||||
cache_adapter.ingest_new_data(
|
||||
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
|
||||
('1', ),
|
||||
SubsonicAPI.PlaylistWithSongs('1', 'foo', songs=songs),
|
||||
("1",),
|
||||
SubsonicAPI.PlaylistWithSongs("1", "foo", songs=songs),
|
||||
)
|
||||
|
||||
playlist = cache_adapter.get_playlist_details('1')
|
||||
assert playlist.id == '1'
|
||||
assert playlist.name == 'foo'
|
||||
playlist = cache_adapter.get_playlist_details("1")
|
||||
assert playlist.id == "1"
|
||||
assert playlist.name == "foo"
|
||||
assert playlist.song_count == 3
|
||||
assert playlist.duration == timedelta(seconds=53.8)
|
||||
for actual, song in zip(playlist.songs, songs):
|
||||
@@ -166,12 +166,12 @@ def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
|
||||
assert getattr(actual, k, None) == v
|
||||
|
||||
with pytest.raises(CacheMissError):
|
||||
cache_adapter.get_playlist_details('2')
|
||||
cache_adapter.get_playlist_details("2")
|
||||
|
||||
|
||||
def test_no_caching_get_playlist_details(adapter: FilesystemAdapter):
|
||||
with pytest.raises(Exception):
|
||||
adapter.get_playlist_details('1')
|
||||
adapter.get_playlist_details("1")
|
||||
|
||||
# TODO: Create a playlist (that should be allowed only if this is acting as
|
||||
# a ground truth adapter)
|
||||
@@ -186,48 +186,45 @@ def test_caching_get_playlist_then_details(cache_adapter: FilesystemAdapter):
|
||||
cache_adapter.ingest_new_data(
|
||||
FilesystemAdapter.FunctionNames.GET_PLAYLISTS,
|
||||
(),
|
||||
[
|
||||
SubsonicAPI.Playlist('1', 'test1'),
|
||||
SubsonicAPI.Playlist('2', 'test2'),
|
||||
],
|
||||
[SubsonicAPI.Playlist("1", "test1"), SubsonicAPI.Playlist("2", "test2")],
|
||||
)
|
||||
|
||||
# Trying to get playlist details should generate a cache miss, but should
|
||||
# include the data that we know about.
|
||||
try:
|
||||
cache_adapter.get_playlist_details('1')
|
||||
assert False, 'DID NOT raise CacheMissError'
|
||||
cache_adapter.get_playlist_details("1")
|
||||
assert 0, "DID NOT raise CacheMissError"
|
||||
except CacheMissError as e:
|
||||
assert e.partial_data
|
||||
assert e.partial_data.id == '1'
|
||||
assert e.partial_data.name == 'test1'
|
||||
assert e.partial_data.id == "1"
|
||||
assert e.partial_data.name == "test1"
|
||||
|
||||
# Simulate getting playlist details for id=1, then id=2
|
||||
songs = [
|
||||
SubsonicAPI.Song(
|
||||
'3',
|
||||
'Song 3',
|
||||
parent='foo',
|
||||
album='foo',
|
||||
artist='foo',
|
||||
"3",
|
||||
"Song 3",
|
||||
parent="foo",
|
||||
album="foo",
|
||||
artist="foo",
|
||||
duration=timedelta(seconds=10.2),
|
||||
path='/foo/song3.mp3',
|
||||
path="/foo/song3.mp3",
|
||||
),
|
||||
]
|
||||
cache_adapter.ingest_new_data(
|
||||
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
|
||||
('1', ),
|
||||
SubsonicAPI.PlaylistWithSongs('1', 'test1', songs=songs),
|
||||
("1",),
|
||||
SubsonicAPI.PlaylistWithSongs("1", "test1", songs=songs),
|
||||
)
|
||||
|
||||
cache_adapter.ingest_new_data(
|
||||
FilesystemAdapter.FunctionNames.GET_PLAYLIST_DETAILS,
|
||||
('2', ),
|
||||
SubsonicAPI.PlaylistWithSongs('2', 'test2', songs=songs),
|
||||
("2",),
|
||||
SubsonicAPI.PlaylistWithSongs("2", "test2", songs=songs),
|
||||
)
|
||||
|
||||
# Going back and getting playlist details for the first one should not
|
||||
# cache miss.
|
||||
playlist = cache_adapter.get_playlist_details('1')
|
||||
assert playlist.id == '1'
|
||||
assert playlist.name == 'test1'
|
||||
playlist = cache_adapter.get_playlist_details("1")
|
||||
assert playlist.id == "1"
|
||||
assert playlist.name == "test1"
|
||||
|
@@ -12,16 +12,16 @@ from sublime.adapters.subsonic import (
|
||||
SubsonicAdapter,
|
||||
)
|
||||
|
||||
MOCK_DATA_FILES = Path(__file__).parent.joinpath('mock_data')
|
||||
MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(tmp_path: Path):
|
||||
adapter = SubsonicAdapter(
|
||||
{
|
||||
'server_address': 'http://subsonic.example.com',
|
||||
'username': 'test',
|
||||
'password': 'testpass',
|
||||
"server_address": "http://subsonic.example.com",
|
||||
"username": "test",
|
||||
"password": "testpass",
|
||||
},
|
||||
tmp_path,
|
||||
)
|
||||
@@ -30,47 +30,39 @@ def adapter(tmp_path: Path):
|
||||
|
||||
|
||||
def mock_data_files(
|
||||
request_name: str,
|
||||
mode: str = 'r',
|
||||
request_name: str, mode: str = "r"
|
||||
) -> Generator[Tuple[Path, Any], None, None]:
|
||||
"""
|
||||
Yields all of the files in the mock_data directory that start with
|
||||
``request_name``.
|
||||
Yields all of the files in the mock_data directory that start with ``request_name``.
|
||||
"""
|
||||
for file in MOCK_DATA_FILES.iterdir():
|
||||
if file.name.split('-')[0] in request_name:
|
||||
if file.name.split("-")[0] in request_name:
|
||||
with open(file, mode) as f:
|
||||
yield file, f.read()
|
||||
|
||||
|
||||
def mock_json(**obj: Any) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
'subsonic-response': {
|
||||
'status': 'ok',
|
||||
'version': '1.15.0',
|
||||
**obj,
|
||||
},
|
||||
})
|
||||
{"subsonic-response": {"status": "ok", "version": "1.15.0", **obj}}
|
||||
)
|
||||
|
||||
|
||||
def camel_to_snake(name: str) -> str:
|
||||
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
|
||||
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
||||
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
|
||||
|
||||
|
||||
def test_request_making_methods(adapter: SubsonicAdapter):
|
||||
expected = {
|
||||
'u': 'test',
|
||||
'p': 'testpass',
|
||||
'c': 'Sublime Music',
|
||||
'f': 'json',
|
||||
'v': '1.15.0',
|
||||
"u": "test",
|
||||
"p": "testpass",
|
||||
"c": "Sublime Music",
|
||||
"f": "json",
|
||||
"v": "1.15.0",
|
||||
}
|
||||
assert (sorted(expected.items()) == sorted(adapter._get_params().items()))
|
||||
assert sorted(expected.items()) == sorted(adapter._get_params().items())
|
||||
|
||||
assert adapter._make_url(
|
||||
'foo') == 'http://subsonic.example.com/rest/foo.view'
|
||||
assert adapter._make_url("foo") == "http://subsonic.example.com/rest/foo.view"
|
||||
|
||||
|
||||
def test_can_service_requests(adapter: SubsonicAdapter):
|
||||
@@ -79,7 +71,7 @@ def test_can_service_requests(adapter: SubsonicAdapter):
|
||||
assert adapter.can_service_requests is False
|
||||
|
||||
# Simulate some sort of ping error
|
||||
for filename, data in mock_data_files('ping_failed'):
|
||||
for filename, data in mock_data_files("ping_failed"):
|
||||
logging.info(filename)
|
||||
logging.debug(data)
|
||||
adapter._set_mock_data(data)
|
||||
@@ -93,32 +85,32 @@ def test_can_service_requests(adapter: SubsonicAdapter):
|
||||
def test_get_playlists(adapter: SubsonicAdapter):
|
||||
expected = [
|
||||
SubsonicAPI.Playlist(
|
||||
id='2',
|
||||
name='Test',
|
||||
id="2",
|
||||
name="Test",
|
||||
song_count=132,
|
||||
duration=timedelta(seconds=33072),
|
||||
created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=timezone.utc),
|
||||
changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=timezone.utc),
|
||||
comment='Foo',
|
||||
owner='foo',
|
||||
comment="Foo",
|
||||
owner="foo",
|
||||
public=True,
|
||||
cover_art='pl-2',
|
||||
cover_art="pl-2",
|
||||
),
|
||||
SubsonicAPI.Playlist(
|
||||
id='3',
|
||||
name='Bar',
|
||||
id="3",
|
||||
name="Bar",
|
||||
song_count=23,
|
||||
duration=timedelta(seconds=847),
|
||||
created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=timezone.utc),
|
||||
changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=timezone.utc),
|
||||
comment='',
|
||||
owner='foo',
|
||||
comment="",
|
||||
owner="foo",
|
||||
public=False,
|
||||
cover_art='pl-3',
|
||||
cover_art="pl-3",
|
||||
),
|
||||
]
|
||||
|
||||
for filename, data in mock_data_files('get_playlists'):
|
||||
for filename, data in mock_data_files("get_playlists"):
|
||||
logging.info(filename)
|
||||
logging.debug(data)
|
||||
adapter._set_mock_data(data)
|
||||
@@ -130,12 +122,12 @@ def test_get_playlists(adapter: SubsonicAdapter):
|
||||
|
||||
|
||||
def test_get_playlist_details(adapter: SubsonicAdapter):
|
||||
for filename, data in mock_data_files('get_playlist_details'):
|
||||
for filename, data in mock_data_files("get_playlist_details"):
|
||||
logging.info(filename)
|
||||
logging.debug(data)
|
||||
adapter._set_mock_data(data)
|
||||
|
||||
playlist_details = adapter.get_playlist_details('2')
|
||||
playlist_details = adapter.get_playlist_details("2")
|
||||
|
||||
# Make sure that the song count is correct even if it's not provided.
|
||||
# Old versions of Subsonic don't have these properties.
|
||||
@@ -144,15 +136,15 @@ def test_get_playlist_details(adapter: SubsonicAdapter):
|
||||
|
||||
# Make sure that at least the first song got decoded properly.
|
||||
assert playlist_details.songs[0] == SubsonicAPI.Song(
|
||||
id='202',
|
||||
parent='318',
|
||||
title='What a Beautiful Name',
|
||||
album='What a Beautiful Name - Single',
|
||||
artist='Hillsong Worship',
|
||||
id="202",
|
||||
parent="318",
|
||||
title="What a Beautiful Name",
|
||||
album="What a Beautiful Name - Single",
|
||||
artist="Hillsong Worship",
|
||||
track=1,
|
||||
year=2016,
|
||||
genre='Christian & Gospel',
|
||||
cover_art='318',
|
||||
genre="Christian & Gospel",
|
||||
cover_art="318",
|
||||
size=8381640,
|
||||
content_type="audio/mp4",
|
||||
suffix="m4a",
|
||||
@@ -160,12 +152,13 @@ def test_get_playlist_details(adapter: SubsonicAdapter):
|
||||
transcoded_suffix="mp3",
|
||||
duration=timedelta(seconds=238),
|
||||
bit_rate=256,
|
||||
path='/'.join(
|
||||
path="/".join(
|
||||
(
|
||||
'Hillsong Worship',
|
||||
'What a Beautiful Name - Single',
|
||||
'01 What a Beautiful Name.m4a',
|
||||
)),
|
||||
"Hillsong Worship",
|
||||
"What a Beautiful Name - Single",
|
||||
"01 What a Beautiful Name.m4a",
|
||||
)
|
||||
),
|
||||
is_video=False,
|
||||
play_count=20,
|
||||
disc_number=1,
|
||||
|
@@ -4,38 +4,32 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from sublime.config import (
|
||||
AppConfiguration, ReplayGainType, ServerConfiguration)
|
||||
from sublime.config import AppConfiguration, ReplayGainType, ServerConfiguration
|
||||
|
||||
|
||||
def test_config_default_cache_location():
|
||||
config = AppConfiguration()
|
||||
assert config.cache_location == os.path.expanduser(
|
||||
'~/.local/share/sublime-music')
|
||||
assert config.cache_location == os.path.expanduser("~/.local/share/sublime-music")
|
||||
|
||||
|
||||
def test_server_property():
|
||||
config = AppConfiguration()
|
||||
server = ServerConfiguration(
|
||||
name='foo', server_address='bar', username='baz')
|
||||
server = ServerConfiguration(name="foo", server_address="bar", username="baz")
|
||||
config.servers.append(server)
|
||||
assert config.server is None
|
||||
config.current_server_index = 0
|
||||
assert asdict(config.server) == asdict(server)
|
||||
|
||||
expected_state_file_location = Path('~/.local/share').expanduser()
|
||||
expected_state_file_location = Path("~/.local/share").expanduser()
|
||||
expected_state_file_location = expected_state_file_location.joinpath(
|
||||
'sublime-music',
|
||||
'6df23dc03f9b54cc38a0fc1483df6e21',
|
||||
'state.pickle',
|
||||
"sublime-music", "6df23dc03f9b54cc38a0fc1483df6e21", "state.pickle",
|
||||
)
|
||||
assert config.state_file_location == expected_state_file_location
|
||||
|
||||
|
||||
def test_yaml_load_unload():
|
||||
config = AppConfiguration()
|
||||
server = ServerConfiguration(
|
||||
name='foo', server_address='bar', username='baz')
|
||||
server = ServerConfiguration(name="foo", server_address="bar", username="baz")
|
||||
config.servers.append(server)
|
||||
config.current_server_index = 0
|
||||
|
||||
@@ -54,9 +48,7 @@ def test_yaml_load_unload():
|
||||
def test_config_migrate():
|
||||
config = AppConfiguration()
|
||||
server = ServerConfiguration(
|
||||
name='Test',
|
||||
server_address='https://test.host',
|
||||
username='test',
|
||||
name="Test", server_address="https://test.host", username="test"
|
||||
)
|
||||
config.servers.append(server)
|
||||
config.migrate()
|
||||
|
Reference in New Issue
Block a user