Run black on entire project

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

23
.editorconfig Normal file
View File

@@ -0,0 +1,23 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 4
[*.py]
max_line_length = 88
[Makefile]
indent_style = tab
# Indentation override for all JSON/YAML files
[*.{json,yaml,yml}]
indent_style = space
indent_size = 2

View File

@@ -19,6 +19,7 @@ lint:
- ./cicd/install-project-deps.sh
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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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"]},
)

View File

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

View File

@@ -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",
)

View File

@@ -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,
):
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.
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 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.
"""

View File

@@ -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
if (
not force
and AdapterManager._instance.caching_adapter
.can_get_playlist_details):
and AdapterManager._instance.caching_adapter.can_service_requests
and AdapterManager._instance.caching_adapter.can_get_playlist_details
):
try:
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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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__)
klass_name = "{}{}Through".format(lhs.__name__, rhs.__name__)
return type(klass_name, (Model,), attrs)

View File

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

View File

@@ -16,34 +16,30 @@ class SubsonicAdapter(Adapter):
"""
Defines an adapter which retrieves its data from a Subsonic server
"""
# 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,29 +49,29 @@ 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,
@@ -84,13 +80,14 @@ class SubsonicAdapter(Adapter):
**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

View File

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

View File

@@ -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)]
], ))
],
),
)
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,

View File

@@ -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,36 +428,33 @@ 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(
@@ -471,7 +464,7 @@ class CacheManager(metaclass=Singleton):
before_download: Callable[[], None] = lambda: None,
force: bool = False,
allow_download: bool = True,
) -> 'CacheManager.Result[str]':
) -> "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:
@@ -666,8 +656,8 @@ class CacheManager(metaclass=Singleton):
self,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[List[Artist]]':
cache_name = 'indexes'
) -> "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])
@@ -694,12 +684,11 @@ class CacheManager(metaclass=Singleton):
id: int,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[Directory]':
cache_name = 'music_directories'
) -> "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:
@@ -717,12 +706,11 @@ class CacheManager(metaclass=Singleton):
artist_id: int,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[ArtistInfo2]':
cache_name = 'artist_infos'
) -> "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,8 +721,7 @@ 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,
)
@@ -744,48 +731,53 @@ class CacheManager(metaclass=Singleton):
artist: Union[Artist, ArtistID3],
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[str]':
) -> "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.
@@ -946,13 +929,14 @@ class CacheManager(metaclass=Singleton):
before_download: Callable[[], None] = lambda: None,
force: bool = False,
allow_download: bool = True,
) -> 'CacheManager.Result[str]':
) -> "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,
@@ -964,11 +948,10 @@ class CacheManager(metaclass=Singleton):
song_id: int,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[Child]':
cache_name = 'song_details'
) -> "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:
@@ -1014,13 +992,13 @@ class CacheManager(metaclass=Singleton):
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'])
) -> "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()

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple
from deepdiff import DeepDiff
from 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,7 +38,8 @@ class DBusManager:
def __init__(
self,
connection: Gio.DBusConnection,
do_on_method_call: Callable[[
do_on_method_call: Callable[
[
Gio.DBusConnection,
str,
str,
@@ -46,11 +47,13 @@ class DBusManager:
str,
GLib.Variant,
Gio.DBusMethodInvocation,
], None],
],
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,
@@ -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),
),
),
)
get_playlists_result = CacheManager.get_playlists()
if get_playlists_result.is_future:
playlist_count = 0
else:
# If we have to wait for the playlist result, just return
# no playlist.
active_playlist = (False, GLib.Variant("(oss)", ("/", "", "")))
get_playlists_result = AdapterManager.get_playlists()
if get_playlists_result.data_is_available:
playlist_count = len(get_playlists_result.result())
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.

View File

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

View File

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

View File

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

View File

@@ -6,15 +6,15 @@ from sublime.from_json import from_json as _from_json
class APIObject:
"""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)

View File

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

View File

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

View File

@@ -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,7 +258,7 @@ 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,
@@ -294,9 +269,13 @@ class AlbumsGrid(Gtk.Overlay):
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()

View File

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

View File

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

View File

@@ -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",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,11 +222,7 @@ 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(
@@ -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
@@ -355,18 +335,18 @@ class MainWindow(Gtk.ApplicationWindow):
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

View File

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

View File

@@ -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)
@@ -380,8 +366,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
# 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)

View File

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

View File

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

View File

@@ -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('&', '&amp;').replace(" target='_blank'", '')
return ""
return string.replace("&", "&amp;").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)

View File

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

View File

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

View File

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