Merge branch '139-chromecast-support-not-required'
This commit is contained in:
@@ -1,9 +1,60 @@
|
||||
v0.10.3
|
||||
v0.10.4
|
||||
=======
|
||||
|
||||
.. TODO in next release:
|
||||
.. * A man page has been added. Contributed by @baldurmen.
|
||||
|
||||
.. note::
|
||||
|
||||
This version does not have a Flatpak due to issues getting Python 3.8 working
|
||||
within the Flatpak environment. See `Issue #218
|
||||
<https://gitlab.com/sumner/sublime-music/-/issues/218_>`_
|
||||
|
||||
**New Website:** Sublime Music has a website! https://sublimemusic.app
|
||||
|
||||
**Distro Packages**
|
||||
|
||||
* Sublime Music is now available in Debian Unstable, and hopefully soon in
|
||||
Debian Testing.
|
||||
* *For package maintainers:*
|
||||
|
||||
The following dependencies were added:
|
||||
|
||||
* ``semver``
|
||||
|
||||
The following dependencies were removed:
|
||||
|
||||
* ``pyyaml``
|
||||
|
||||
The following dependencies are now optional:
|
||||
|
||||
* ``pychromecast``
|
||||
* ``bottle``
|
||||
|
||||
**Feature Improvements**
|
||||
|
||||
* Player settings now get applied immediately, rather than after restarting
|
||||
Sublime Music.
|
||||
* Getting the list of Chromecasts for the Device popup now happens much faster.
|
||||
|
||||
**Bug Fixes**
|
||||
|
||||
* Loading the play queue from the server is now more reliable and works properly
|
||||
with Gonic (Contributed by @sentriz).
|
||||
* *Fixed Regression*: The load play queue button in the play queue popup works
|
||||
again.
|
||||
* Caching behavior has been greatly improved.
|
||||
* The Subsonic adapter disables saving and loading the play queue if the server
|
||||
doesn't implement the Subsonic API v1.12.0.
|
||||
|
||||
**Under the Hood**
|
||||
|
||||
* The API for players has been greatly improved and is now actually documented
|
||||
which will enable more player types in the future.
|
||||
|
||||
v0.10.3
|
||||
=======
|
||||
|
||||
This is a hotfix release. I forgot to add the Subsonic logo resources to
|
||||
``setup.py``. All of the interesting updates happened in `v0.10.2`_.
|
||||
|
||||
|
@@ -47,11 +47,11 @@ install to develop the app. In general, the requirements are:
|
||||
- GTK3
|
||||
- GLib
|
||||
|
||||
Specific Install Instructions for Various Distros/OSes
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Specific Requirements for Various Distros/OSes
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
* **Arch Linux:** `pacman -S libnm-glib libnotify python-gobject`
|
||||
* **macOS (Homebrew):** `brew install pygobject3 gtk+3 adwaita-icon-theme`
|
||||
* **macOS (Homebrew):** `brew install mp3 gobject-introspection pkg-config pygobject3 gtk+3 adwaita-icon-theme`
|
||||
|
||||
Installing
|
||||
----------
|
||||
|
2
Pipfile
2
Pipfile
@@ -26,7 +26,7 @@ sphinx-rtd-theme = "*"
|
||||
termcolor = "*"
|
||||
|
||||
[packages]
|
||||
sublime-music = {editable = true,extras = ["keyring"],path = "."}
|
||||
sublime-music = {editable = true, extras = ["chromecast", "keyring", "server"], path = "."}
|
||||
|
||||
[requires]
|
||||
python_version = "3.8"
|
||||
|
417
Pipfile.lock
generated
417
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "a04cebbd47f79d5d7fa2266238fde4b5530a0271d5bb2a514e579a1eed3632f6"
|
||||
"sha256": "f018bc2d21d6dc296af872daca484440360496dc7fd3746880da2fe2996ed0ce"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -32,10 +32,10 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
|
||||
"sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
|
||||
"sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
|
||||
"sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
|
||||
],
|
||||
"version": "==2020.4.5.1"
|
||||
"version": "==2020.6.20"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
@@ -99,21 +99,24 @@
|
||||
"sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785",
|
||||
"sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.9.2"
|
||||
},
|
||||
"dataclasses-json": {
|
||||
"hashes": [
|
||||
"sha256:78ec3cadb6576a19fd47c82087804e568f81685a1bfb20a03aaf86bc631e3dff",
|
||||
"sha256:cd5180121bc2428892f4e796f5eafca5ba88f87e263b2ed3b5d4fd1bb12a1c28"
|
||||
"sha256:2f5fca4f097f9f9727e2100c824ed48153171186a679487f8875eca8d8c05107",
|
||||
"sha256:6e38b11b178e404124bffd6d213736bc505338e8a4c718596efec8d32eb96f5a"
|
||||
],
|
||||
"version": "==0.4.4"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.5.1"
|
||||
},
|
||||
"deepdiff": {
|
||||
"hashes": [
|
||||
"sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4",
|
||||
"sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"
|
||||
"sha256:05bb6241255f9a09c982e67b24bfc84437c2a4d602cef9e9b3ccfa54f0699ea2",
|
||||
"sha256:1c1956b195b5cbf34505e23902a9e711d9c68fbb73a8d58a4b560fad16dae8b7"
|
||||
],
|
||||
"version": "==4.3.2"
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==5.0.1"
|
||||
},
|
||||
"fuzzywuzzy": {
|
||||
"hashes": [
|
||||
@@ -124,16 +127,18 @@
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
|
||||
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"version": "==2.9"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.10"
|
||||
},
|
||||
"ifaddr": {
|
||||
"hashes": [
|
||||
"sha256:c19c64882a7ad51a394451dabcbbed72e98b5625ec1e79789924d5ea3e3ecb93"
|
||||
"sha256:1f9e8a6ca6f16db5a37d3356f07b6e52344f6f9f7e806d618537731669eb1a94",
|
||||
"sha256:d1f603952f0a71c9ab4e705754511e4e03b02565bc4cec7188ad6415ff534cd3"
|
||||
],
|
||||
"version": "==0.1.6"
|
||||
"version": "==0.1.7"
|
||||
},
|
||||
"jeepney": {
|
||||
"hashes": [
|
||||
@@ -148,14 +153,16 @@
|
||||
"sha256:3401234209015144a5d75701e71cb47239e552b0882313e9f51e8976f9e27843",
|
||||
"sha256:c53e0e5ccde3ad34284a40ce7976b5b3a3d6de70344c3f8ee44364cc340976ec"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==21.2.1"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:c2673233aa21dde264b84349dc2fd1dce5f30ed724a0a00e75426734de5b84ab",
|
||||
"sha256:f88fe96434b1f0f476d54224d59333eba8ca1a203a2695683c1855675c4049a7"
|
||||
"sha256:35ee2fb188f0bd9fc1cf9ac35e45fd394bd1c153cee430745a465ea435514bd5",
|
||||
"sha256:9aa20f9b71c992b4782dad07c51d92884fd0f7c5cb9d3c737bea17ec1bad765f"
|
||||
],
|
||||
"version": "==3.6.0"
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.6.1"
|
||||
},
|
||||
"marshmallow-enum": {
|
||||
"hashes": [
|
||||
@@ -173,9 +180,10 @@
|
||||
},
|
||||
"ordered-set": {
|
||||
"hashes": [
|
||||
"sha256:a31008c57f9c9776b12eb8841b1f61d1e4d70dfbbe8875ccfa2403c54af3d51b"
|
||||
"sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"
|
||||
],
|
||||
"version": "==4.0.1"
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==4.0.2"
|
||||
},
|
||||
"peewee": {
|
||||
"hashes": [
|
||||
@@ -210,26 +218,29 @@
|
||||
"hashes": [
|
||||
"sha256:2c143183280feb67f5beb4e543fd49990c28e7df427301ede04fc550d3562e84"
|
||||
],
|
||||
"markers": "python_version >= '3.5' and python_version < '4'",
|
||||
"version": "==1.19.1"
|
||||
},
|
||||
"pychromecast": {
|
||||
"hashes": [
|
||||
"sha256:4dfe07c464bb51544bfe15386f92b74ad08cd33c837cf4c5b90a9d6c79c51c97",
|
||||
"sha256:c5a0e3e1683f07b9113eab5d4b9bebb1697cb52f4f020fadf80b10214fed256c"
|
||||
"sha256:87fa9ad42425edd21e02a9240669e5763e52d975ee259a948a6fe07e6ab977b9",
|
||||
"sha256:c35ffdde55c6b30dd6773396e45e035a178163d4e64c3405260441031933e9f2"
|
||||
],
|
||||
"version": "==5.3.0"
|
||||
"version": "==7.1.1"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
|
||||
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.20"
|
||||
},
|
||||
"pygobject": {
|
||||
"hashes": [
|
||||
"sha256:012a589aec687bfa809a1ff9f5cd775dc7f6fcec1a6bc7fe88e1002a68f8ba34"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==3.36.1"
|
||||
},
|
||||
"python-dateutil": {
|
||||
@@ -237,6 +248,7 @@
|
||||
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
|
||||
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.8.1"
|
||||
},
|
||||
"python-levenshtein": {
|
||||
@@ -251,28 +263,13 @@
|
||||
],
|
||||
"version": "==0.4.6"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
|
||||
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
|
||||
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
|
||||
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
|
||||
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
|
||||
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
|
||||
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
|
||||
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
|
||||
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
|
||||
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
|
||||
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
|
||||
],
|
||||
"version": "==5.3.1"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
|
||||
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
|
||||
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
|
||||
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
|
||||
],
|
||||
"version": "==2.23.0"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.24.0"
|
||||
},
|
||||
"secretstorage": {
|
||||
"hashes": [
|
||||
@@ -282,11 +279,20 @@
|
||||
"markers": "sys_platform == 'linux'",
|
||||
"version": "==3.1.2"
|
||||
},
|
||||
"semver": {
|
||||
"hashes": [
|
||||
"sha256:21e80ca738975ed513cba859db0a0d2faca2380aef1962f48272ebf9a8a44bd4",
|
||||
"sha256:c0a4a9d1e45557297a722ee9bac3de2ec2ea79016b6ffcaca609b0bc62cf4276"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.10.2"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"stringcase": {
|
||||
@@ -298,7 +304,9 @@
|
||||
"sublime-music": {
|
||||
"editable": true,
|
||||
"extras": [
|
||||
"keyring"
|
||||
"chromecast",
|
||||
"keyring",
|
||||
"server"
|
||||
],
|
||||
"path": "."
|
||||
},
|
||||
@@ -323,14 +331,17 @@
|
||||
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
|
||||
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==1.25.9"
|
||||
},
|
||||
"zeroconf": {
|
||||
"hashes": [
|
||||
"sha256:6410cd8252e89719f33a30b1bf5fbae473b66d390327229a57ae655bc0fe557c",
|
||||
"sha256:74093f3c4e90898b992b47d324ac4c991fbb372d8dbbadfbf38cd25eae2586fd"
|
||||
"editable": true,
|
||||
"extras": [
|
||||
"chromecast",
|
||||
"keyring",
|
||||
"server"
|
||||
],
|
||||
"version": "==0.27.0"
|
||||
"path": "."
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@@ -353,6 +364,7 @@
|
||||
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
||||
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==19.3.0"
|
||||
},
|
||||
"babel": {
|
||||
@@ -360,6 +372,7 @@
|
||||
"sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
|
||||
"sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.8.0"
|
||||
},
|
||||
"black": {
|
||||
@@ -372,10 +385,10 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
|
||||
"sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
|
||||
"sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
|
||||
"sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
|
||||
],
|
||||
"version": "==2020.4.5.1"
|
||||
"version": "==2020.6.20"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
@@ -389,43 +402,48 @@
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
"sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
|
||||
"sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355",
|
||||
"sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65",
|
||||
"sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7",
|
||||
"sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9",
|
||||
"sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1",
|
||||
"sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0",
|
||||
"sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55",
|
||||
"sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c",
|
||||
"sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6",
|
||||
"sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef",
|
||||
"sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019",
|
||||
"sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e",
|
||||
"sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0",
|
||||
"sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf",
|
||||
"sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24",
|
||||
"sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2",
|
||||
"sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c",
|
||||
"sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4",
|
||||
"sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0",
|
||||
"sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd",
|
||||
"sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04",
|
||||
"sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e",
|
||||
"sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730",
|
||||
"sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2",
|
||||
"sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768",
|
||||
"sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796",
|
||||
"sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7",
|
||||
"sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a",
|
||||
"sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489",
|
||||
"sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"
|
||||
"sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d",
|
||||
"sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2",
|
||||
"sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703",
|
||||
"sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404",
|
||||
"sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7",
|
||||
"sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405",
|
||||
"sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d",
|
||||
"sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c",
|
||||
"sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6",
|
||||
"sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70",
|
||||
"sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40",
|
||||
"sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4",
|
||||
"sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613",
|
||||
"sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10",
|
||||
"sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b",
|
||||
"sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0",
|
||||
"sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec",
|
||||
"sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1",
|
||||
"sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d",
|
||||
"sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913",
|
||||
"sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e",
|
||||
"sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62",
|
||||
"sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e",
|
||||
"sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a",
|
||||
"sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d",
|
||||
"sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f",
|
||||
"sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e",
|
||||
"sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b",
|
||||
"sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c",
|
||||
"sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032",
|
||||
"sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a",
|
||||
"sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee",
|
||||
"sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c",
|
||||
"sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b"
|
||||
],
|
||||
"version": "==5.1"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==5.2"
|
||||
},
|
||||
"docutils": {
|
||||
"hashes": [
|
||||
@@ -437,19 +455,19 @@
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634",
|
||||
"sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5"
|
||||
"sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
|
||||
"sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.8.2"
|
||||
"version": "==3.8.3"
|
||||
},
|
||||
"flake8-annotations": {
|
||||
"hashes": [
|
||||
"sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554",
|
||||
"sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75"
|
||||
"sha256:babc81a17a5f1a63464195917e20d3e8663fb712b3633d4522dbfc407cff31b3",
|
||||
"sha256:fcd833b415726a7a374922c95a5c47a7a4d8ea71cb4a586369c665e7476146e1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.1.0"
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"flake8-bugbear": {
|
||||
"hashes": [
|
||||
@@ -461,11 +479,11 @@
|
||||
},
|
||||
"flake8-comprehensions": {
|
||||
"hashes": [
|
||||
"sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2",
|
||||
"sha256:e7db586bb6eb95afdfd87ed244c90e57ae1352db8ef0ad3012fca0200421e5df"
|
||||
"sha256:44eaae9894aa15f86e0c86df1e218e7917494fab6f96d28f96a029c460f17d92",
|
||||
"sha256:d5751acc0f7364794c71d06f113f4686d6e2e26146a50fa93130b9f200fe160d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.2.2"
|
||||
"version": "==3.2.3"
|
||||
},
|
||||
"flake8-import-order": {
|
||||
"hashes": [
|
||||
@@ -507,24 +525,27 @@
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
|
||||
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"version": "==2.9"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.10"
|
||||
},
|
||||
"imagesize": {
|
||||
"hashes": [
|
||||
"sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
|
||||
"sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.2.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
|
||||
"sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
|
||||
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||
],
|
||||
"version": "==3.0.0a1"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.11.2"
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
@@ -561,30 +582,42 @@
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"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"
|
||||
"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"
|
||||
],
|
||||
"version": "==2.0.0a1"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
@@ -595,30 +628,31 @@
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be",
|
||||
"sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"
|
||||
"sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5",
|
||||
"sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"
|
||||
],
|
||||
"version": "==8.3.0"
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==8.4.0"
|
||||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2",
|
||||
"sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1",
|
||||
"sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164",
|
||||
"sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761",
|
||||
"sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce",
|
||||
"sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27",
|
||||
"sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754",
|
||||
"sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae",
|
||||
"sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9",
|
||||
"sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600",
|
||||
"sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65",
|
||||
"sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8",
|
||||
"sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913",
|
||||
"sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3"
|
||||
"sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c",
|
||||
"sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86",
|
||||
"sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b",
|
||||
"sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd",
|
||||
"sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc",
|
||||
"sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea",
|
||||
"sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e",
|
||||
"sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308",
|
||||
"sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406",
|
||||
"sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d",
|
||||
"sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707",
|
||||
"sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d",
|
||||
"sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c",
|
||||
"sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.770"
|
||||
"version": "==0.782"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
@@ -632,6 +666,7 @@
|
||||
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
|
||||
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.4"
|
||||
},
|
||||
"pathspec": {
|
||||
@@ -646,14 +681,16 @@
|
||||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
|
||||
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
|
||||
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
|
||||
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
|
||||
],
|
||||
"version": "==1.8.1"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
@@ -668,6 +705,7 @@
|
||||
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
|
||||
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pygments": {
|
||||
@@ -675,30 +713,32 @@
|
||||
"sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44",
|
||||
"sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==2.6.1"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:67199f0c41a9c702154efb0e7a8cc08accf830eb003b4d9fa42c4059002e2492",
|
||||
"sha256:700d17888d441604b0bd51535908dcb297561b040819cccde647a92439db5a2a"
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"version": "==3.0.0a1"
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3",
|
||||
"sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698"
|
||||
"sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1",
|
||||
"sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.4.2"
|
||||
"version": "==5.4.3"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
"sha256:b6a814b8ed6247bd81ff47f038511b57fe1ce7f4cc25b9106f1a4b106f1d9322",
|
||||
"sha256:c87dfd8465d865655a8213859f1b4749b43448b5fae465cb981e16d52a811424"
|
||||
"sha256:1a629dc9f48e53512fcbfda6b07de490c374b0c83c55ff7a1720b3fccff0ac87",
|
||||
"sha256:6e6d18092dce6fad667cd7020deed816f858ad3b49d5b5e2b1cc1c97a4dba65c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.9.0"
|
||||
"version": "==2.10.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
@@ -709,36 +749,37 @@
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:1386e75c9d1574f6aa2e4eb5355374c8e55f9aac97e224a8a5a6abded0f9c927",
|
||||
"sha256:27ff7325b297fb6e5ebb70d10437592433601c423f5acf86e5bc1ee2919b9561",
|
||||
"sha256:329ba35d711e3428db6b45a53b1b13a0a8ba07cbbcf10bbed291a7da45f106c3",
|
||||
"sha256:3a9394197664e35566242686d84dfd264c07b20f93514e2e09d3c2b3ffdf78fe",
|
||||
"sha256:51f17abbe973c7673a61863516bdc9c0ef467407a940f39501e786a07406699c",
|
||||
"sha256:579ea215c81d18da550b62ff97ee187b99f1b135fd894a13451e00986a080cad",
|
||||
"sha256:70c14743320a68c5dac7fc5a0f685be63bc2024b062fe2aaccc4acc3d01b14a1",
|
||||
"sha256:7e61be8a2900897803c293247ef87366d5df86bf701083b6c43119c7c6c99108",
|
||||
"sha256:8044d1c085d49673aadb3d7dc20ef5cb5b030c7a4fa253a593dda2eab3059929",
|
||||
"sha256:89d76ce33d3266173f5be80bd4efcbd5196cafc34100fdab814f9b228dee0fa4",
|
||||
"sha256:99568f00f7bf820c620f01721485cad230f3fb28f57d8fbf4a7967ec2e446994",
|
||||
"sha256:a7c37f048ec3920783abab99f8f4036561a174f1314302ccfa4e9ad31cb00eb4",
|
||||
"sha256:c2062c7d470751b648f1cacc3f54460aebfc261285f14bc6da49c6943bd48bdd",
|
||||
"sha256:c9bce6e006fbe771a02bda468ec40ffccbf954803b470a0345ad39c603402577",
|
||||
"sha256:ce367d21f33e23a84fb83a641b3834dd7dd8e9318ad8ff677fbfae5915a239f7",
|
||||
"sha256:ce450ffbfec93821ab1fea94779a8440e10cf63819be6e176eb1973a6017aff5",
|
||||
"sha256:ce5cc53aa9fbbf6712e92c7cf268274eaff30f6bd12a0754e8133d85a8fb0f5f",
|
||||
"sha256:d466967ac8e45244b9dfe302bbe5e3337f8dc4dec8d7d10f5e950d83b140d33a",
|
||||
"sha256:d881c2e657c51d89f02ae4c21d9adbef76b8325fe4d5cf0e9ad62f850f3a98fd",
|
||||
"sha256:e565569fc28e3ba3e475ec344d87ed3cd8ba2d575335359749298a0899fe122e",
|
||||
"sha256:ea55b80eb0d1c3f1d8d784264a6764f931e172480a2f1868f2536444c5f01e01"
|
||||
"sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a",
|
||||
"sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938",
|
||||
"sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29",
|
||||
"sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae",
|
||||
"sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387",
|
||||
"sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a",
|
||||
"sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf",
|
||||
"sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610",
|
||||
"sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9",
|
||||
"sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5",
|
||||
"sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3",
|
||||
"sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89",
|
||||
"sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded",
|
||||
"sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754",
|
||||
"sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f",
|
||||
"sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868",
|
||||
"sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd",
|
||||
"sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910",
|
||||
"sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3",
|
||||
"sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac",
|
||||
"sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"
|
||||
],
|
||||
"version": "==2020.5.14"
|
||||
"version": "==2020.6.8"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
|
||||
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
|
||||
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
|
||||
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
|
||||
],
|
||||
"version": "==2.23.0"
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.24.0"
|
||||
},
|
||||
"rope": {
|
||||
"hashes": [
|
||||
@@ -759,6 +800,7 @@
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"snowballstemmer": {
|
||||
@@ -770,25 +812,26 @@
|
||||
},
|
||||
"sphinx": {
|
||||
"hashes": [
|
||||
"sha256:779a519adbd3a70fc7c468af08c5e74829868b0a5b34587b33340e010291856c",
|
||||
"sha256:ea64df287958ee5aac46be7ac2b7277305b0381d213728c3a49d8bb9b8415807"
|
||||
"sha256:97dbf2e31fc5684bb805104b8ad34434ed70e6c588f6896991b2fdfd2bef8c00",
|
||||
"sha256:b9daeb9b39aa1ffefc2809b43604109825300300b987a24f45976c001ba1a8fd"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.4"
|
||||
"version": "==3.1.2"
|
||||
},
|
||||
"sphinx-rtd-theme": {
|
||||
"hashes": [
|
||||
"sha256:1ba9bbc8898ed8531ac8d140b4ff286d57010fb878303b2efae3303726ec821b",
|
||||
"sha256:a18194ae459f6a59b0d56e4a8b4c576c0125fb9a12f2211e652b4a8133092e14"
|
||||
"sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d",
|
||||
"sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.5.0rc1"
|
||||
"version": "==0.5.0"
|
||||
},
|
||||
"sphinxcontrib-applehelp": {
|
||||
"hashes": [
|
||||
"sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a",
|
||||
"sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"sphinxcontrib-devhelp": {
|
||||
@@ -796,6 +839,7 @@
|
||||
"sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e",
|
||||
"sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"sphinxcontrib-htmlhelp": {
|
||||
@@ -803,6 +847,7 @@
|
||||
"sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f",
|
||||
"sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.0.3"
|
||||
},
|
||||
"sphinxcontrib-jsmath": {
|
||||
@@ -810,6 +855,7 @@
|
||||
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
|
||||
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-qthelp": {
|
||||
@@ -817,6 +863,7 @@
|
||||
"sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72",
|
||||
"sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.0.3"
|
||||
},
|
||||
"sphinxcontrib-serializinghtml": {
|
||||
@@ -824,6 +871,7 @@
|
||||
"sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc",
|
||||
"sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.1.4"
|
||||
},
|
||||
"termcolor": {
|
||||
@@ -879,14 +927,15 @@
|
||||
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
|
||||
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==1.25.9"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
"sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1",
|
||||
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
|
||||
"sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
|
||||
"sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
|
||||
],
|
||||
"version": "==0.1.9"
|
||||
"version": "==0.2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -49,7 +49,9 @@ def check_file(path: Path) -> bool:
|
||||
valid = True
|
||||
for path in Path("sublime").glob("**/*.py"):
|
||||
valid &= check_file(path)
|
||||
print() # noqa: T001
|
||||
|
||||
for path in Path("tests").glob("**/*.py"):
|
||||
valid &= check_file(path)
|
||||
|
||||
"""
|
||||
Checks that the version in the CHANGELOG is the same as the version in ``__init__.py``.
|
||||
|
@@ -25,7 +25,7 @@ Linux Desktop.
|
||||
Features
|
||||
--------
|
||||
|
||||
* Switch between multiple Subsonic-API-compliant servers.
|
||||
* Switch between multiple Subsonic-API-compliant [1]_ servers.
|
||||
* Play music through Chromecast devices on the same LAN.
|
||||
* Offline Mode where Sublime Music will not make any network requests.
|
||||
* DBus MPRIS interface integration for controlling Sublime Music via clients
|
||||
@@ -37,6 +37,8 @@ Features
|
||||
* Create/delete/edit playlists.
|
||||
* Download songs for offline listening.
|
||||
|
||||
.. [1] Requires a server which implements the Subsonic API version 1.8.0+.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
@@ -46,6 +48,13 @@ Install the |AUR Package|_. Example using ``yay``::
|
||||
|
||||
yay -S sublime-music
|
||||
|
||||
If you want support for storing passwords in the system keychain, also install
|
||||
``python-keyring``.
|
||||
|
||||
If you want support for playing on Chromecast devices, install
|
||||
``python-pychromecast``. If you want to serve cached files from your computer
|
||||
over the LAN to Chromecast devices also install ``python-bottle``.
|
||||
|
||||
.. |AUR Package| replace:: ``sublime-music`` package
|
||||
.. _AUR Package: https://aur.archlinux.org/packages/sublime-music/
|
||||
|
||||
@@ -79,10 +88,17 @@ and run it by executing::
|
||||
|
||||
pip install sublime-music
|
||||
|
||||
Or if you want to store your passwords in the system keyring instead of in
|
||||
plain-text::
|
||||
There are a few optional dependencies that you can install. Here's an example of
|
||||
how to do that::
|
||||
|
||||
pip install sublime-music[keyring]
|
||||
pip install sublime-music[keyring,chromecast,server]
|
||||
|
||||
* ``keyring``: if you want to store your passwords in the system keyring instead
|
||||
of in plain-text
|
||||
* ``chromecast``: if you want support for playing on Chromecast devices on the
|
||||
LAN.
|
||||
* ``server``: if you want to be able to serve cached files from your computer
|
||||
over the LAN to Chromecast devices
|
||||
|
||||
.. note::
|
||||
|
||||
|
@@ -101,6 +101,51 @@
|
||||
}
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"name": "python-3.8",
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://www.python.org/ftp/python/3.8.3/Python-3.8.3.tar.xz",
|
||||
"sha256": "dfab5ec723c218082fe3d5d7ae17ecbdebffa9a1aea4d64aa3a2ecdd2e795864"
|
||||
}
|
||||
],
|
||||
"config-opts": [
|
||||
"--enable-shared",
|
||||
"--with-ensurepip=yes",
|
||||
"--with-system-expat",
|
||||
"--with-system-ffi",
|
||||
"--enable-loadable-sqlite-extensions",
|
||||
"--with-dbmliborder=gdbm",
|
||||
"--enable-unicode=ucs4"
|
||||
],
|
||||
"post-install": [
|
||||
"chmod 644 $FLATPAK_DEST/lib/libpython3.8.so.1.0"
|
||||
],
|
||||
"cleanup": [
|
||||
"/bin/2to3*",
|
||||
"/bin/easy_install*",
|
||||
"/bin/idle*",
|
||||
"/bin/pydoc*",
|
||||
"/bin/python*-config",
|
||||
"/bin/pyvenv*",
|
||||
"/include",
|
||||
"/lib/pkgconfig",
|
||||
"/lib/python*/config",
|
||||
"/share",
|
||||
"/lib/python*/test",
|
||||
"/lib/python*/*/test",
|
||||
"/lib/python*/*/tests",
|
||||
"/lib/python*/lib-tk/test",
|
||||
"/lib/python*/lib-dynload/_*_test.*.so",
|
||||
"/lib/python*/lib-dynload/_test*.*.so",
|
||||
"/lib/python*/idlelib",
|
||||
"/lib/python*/tkinter*",
|
||||
"/lib/python*/turtle*",
|
||||
"/lib/python*/lib2to3*",
|
||||
"/lib/python3.8/config/libpython3.8.a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "luajit",
|
||||
"no-autogen": true,
|
||||
|
@@ -18,10 +18,8 @@ pycparser==2.20
|
||||
python-dateutil==2.8.1
|
||||
python-levenshtein==0.12.0
|
||||
python-mpv==0.4.6
|
||||
pyyaml==5.3.1
|
||||
requests==2.23.0
|
||||
stringcase==1.2.0
|
||||
typing-extensions==3.7.4.2
|
||||
typing-inspect==0.6.0
|
||||
urllib3==1.25.9
|
||||
zeroconf==0.27.0
|
||||
|
@@ -1,5 +1,5 @@
|
||||
[flake8]
|
||||
extend-ignore = E203, E402, E722, W503, ANN002, ANN003, ANN101, ANN102, ANN204
|
||||
extend-ignore = E203, E402, E722, E731, W503, ANN002, ANN003, ANN101, ANN102, ANN204
|
||||
exclude = .git,__pycache__,build,dist,flatpak,.venv
|
||||
max-line-length = 88
|
||||
suppress-none-returning = True
|
||||
@@ -46,6 +46,9 @@ ignore_missing_imports = True
|
||||
[mypy-peewee]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-semver]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[tool:pytest]
|
||||
python_files = tests/**/*.py tests/*.py
|
||||
python_functions = test_* *_test
|
||||
|
13
setup.py
13
setup.py
@@ -47,28 +47,29 @@ setup(
|
||||
"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.8",
|
||||
],
|
||||
keywords="airsonic subsonic libresonic gonic music",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
package_data={"sublime": ["ui/app_styles.css", *package_data_files]},
|
||||
install_requires=[
|
||||
"bottle",
|
||||
"dataclasses-json",
|
||||
"deepdiff",
|
||||
"fuzzywuzzy",
|
||||
'osxmmkeys ; sys_platform=="darwin"',
|
||||
"peewee",
|
||||
"pychromecast",
|
||||
"PyGObject",
|
||||
"python-dateutil",
|
||||
"python-Levenshtein",
|
||||
"python-mpv",
|
||||
"pyyaml",
|
||||
"requests",
|
||||
"semver",
|
||||
],
|
||||
extras_require={"keyring": ["keyring"]},
|
||||
extras_require={
|
||||
"keyring": ["keyring"],
|
||||
"chromecast": ["pychromecast"],
|
||||
"server": ["bottle"],
|
||||
},
|
||||
# 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
|
||||
|
@@ -1 +1 @@
|
||||
__version__ = "0.10.3"
|
||||
__version__ = "0.10.4"
|
||||
|
@@ -10,6 +10,7 @@ from typing import (
|
||||
cast,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
@@ -178,7 +179,7 @@ class ConfigurationStore(dict):
|
||||
values = ", ".join(f"{k}={v!r}" for k, v in sorted(self.items()))
|
||||
return f"ConfigurationStore({values})"
|
||||
|
||||
def get_secret(self, key: str) -> Any:
|
||||
def get_secret(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get the secret value in the store with the given key. If the key doesn't exist
|
||||
in the store, return the default. This will retrieve the secret from whatever is
|
||||
@@ -186,7 +187,7 @@ class ConfigurationStore(dict):
|
||||
with secret storage yourself.
|
||||
"""
|
||||
value = self.get(key)
|
||||
if not isinstance(value, (tuple, list)) or len(value) != 2:
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return None
|
||||
|
||||
storage_type, storage_key = value
|
||||
@@ -195,7 +196,7 @@ class ConfigurationStore(dict):
|
||||
"plaintext": lambda: storage_key,
|
||||
}[storage_type]()
|
||||
|
||||
def set_secret(self, key: str, value: Any = None) -> Any:
|
||||
def set_secret(self, key: str, value: str = None):
|
||||
"""
|
||||
Set the secret value of the given key in the store. This should be used for
|
||||
things such as passwords or API tokens. This will store the secret in whatever
|
||||
@@ -206,7 +207,7 @@ class ConfigurationStore(dict):
|
||||
try:
|
||||
password_id = None
|
||||
if password_type_and_id := self.get(key):
|
||||
if cast(Tuple[str, str], password_type_and_id[0]) == "keyring":
|
||||
if cast(List[str], password_type_and_id)[0] == "keyring":
|
||||
password_id = password_type_and_id[1]
|
||||
|
||||
if password_id is None:
|
||||
@@ -823,6 +824,7 @@ class CachingAdapter(Adapter):
|
||||
ARTISTS = "artists"
|
||||
COVER_ART_FILE = "cover_art_file"
|
||||
DIRECTORY = "directory"
|
||||
GENRE = "genre"
|
||||
GENRES = "genres"
|
||||
IGNORED_ARTICLES = "ignored_articles"
|
||||
PLAYLIST_DETAILS = "get_playlist_details"
|
||||
|
@@ -157,7 +157,7 @@ class ConfigureServerForm(Gtk.Box):
|
||||
if cpd.default is not None:
|
||||
config_store[key] = config_store.get(key, cpd.default)
|
||||
|
||||
label = Gtk.Label(cpd.description + ":", halign=Gtk.Align.END)
|
||||
label = Gtk.Label(label=cpd.description, halign=Gtk.Align.END)
|
||||
|
||||
input_el_box = Gtk.Box()
|
||||
self.entries[key] = cast(
|
||||
|
@@ -4,7 +4,7 @@ import shutil
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast, Dict, Optional, Sequence, Set, Tuple, Union
|
||||
from typing import Any, cast, Dict, Iterable, Optional, Sequence, Set, Tuple
|
||||
|
||||
from gi.repository import Gtk
|
||||
from peewee import fn, prefetch
|
||||
@@ -300,10 +300,11 @@ class FilesystemAdapter(CachingAdapter):
|
||||
filename := self._compute_song_filename(song_file)
|
||||
):
|
||||
if filename.exists():
|
||||
file_uri = f"file://{filename}"
|
||||
if song_file.valid:
|
||||
return str(filename)
|
||||
return file_uri
|
||||
else:
|
||||
raise CacheMissError(partial_data=str(filename))
|
||||
raise CacheMissError(partial_data=file_uri)
|
||||
except models.CacheInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -442,23 +443,21 @@ class FilesystemAdapter(CachingAdapter):
|
||||
with self.db_write_lock, models.database.atomic():
|
||||
self._do_ingest_new_data(data_key, param, data)
|
||||
|
||||
def invalidate_data(
|
||||
self, function: CachingAdapter.CachedDataKey, param: Optional[str]
|
||||
):
|
||||
def invalidate_data(self, key: CachingAdapter.CachedDataKey, param: Optional[str]):
|
||||
assert self.is_cache, "FilesystemAdapter is not in cache mode!"
|
||||
|
||||
# Wrap the actual ingestion function in a database lock, and an atomic
|
||||
# transaction.
|
||||
with self.db_write_lock, models.database.atomic():
|
||||
self._do_invalidate_data(function, param)
|
||||
self._do_invalidate_data(key, param)
|
||||
|
||||
def delete_data(self, function: CachingAdapter.CachedDataKey, param: Optional[str]):
|
||||
def delete_data(self, key: CachingAdapter.CachedDataKey, param: Optional[str]):
|
||||
assert self.is_cache, "FilesystemAdapter is not in cache mode!"
|
||||
|
||||
# Wrap the actual ingestion function in a database lock, and an atomic
|
||||
# transaction.
|
||||
with self.db_write_lock, models.database.atomic():
|
||||
self._do_delete_data(function, param)
|
||||
self._do_delete_data(key, param)
|
||||
|
||||
def _do_ingest_new_data(
|
||||
self,
|
||||
@@ -470,27 +469,203 @@ class FilesystemAdapter(CachingAdapter):
|
||||
# TODO (#201): this entire function is not exactly efficient due to the nested
|
||||
# dependencies and everything. I'm not sure how to improve it, and I'm not sure
|
||||
# if it needs improving at this point.
|
||||
|
||||
# TODO (#201): refactor to to be a recursive function like invalidate_data?
|
||||
|
||||
cache_info_extra: Dict[str, Any] = {}
|
||||
|
||||
logging.debug(
|
||||
f"_do_ingest_new_data param={param} data_key={data_key} data={data}"
|
||||
)
|
||||
|
||||
def getattrs(obj: Any, keys: Iterable[str]) -> Dict[str, Any]:
|
||||
return {k: getattr(obj, k) for k in keys}
|
||||
|
||||
def setattrs(obj: Any, data: Dict[str, Any]):
|
||||
for k, v in data.items():
|
||||
if v:
|
||||
if v is not None:
|
||||
setattr(obj, k, v)
|
||||
|
||||
def ingest_directory_data(api_directory: API.Directory) -> models.Directory:
|
||||
directory_data: Dict[str, Any] = {
|
||||
"id": api_directory.id,
|
||||
"name": api_directory.name,
|
||||
"parent_id": api_directory.parent_id,
|
||||
def compute_file_hash(filename: str) -> str:
|
||||
file_hash = hashlib.sha1()
|
||||
with open(filename, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
file_hash.update(chunk)
|
||||
|
||||
return file_hash.hexdigest()
|
||||
|
||||
return_val = None
|
||||
|
||||
# Set the cache info.
|
||||
now = datetime.now()
|
||||
cache_info, cache_info_created = models.CacheInfo.get_or_create(
|
||||
cache_key=(
|
||||
# In the case of SONG_FILE_PERMANENT, we have to use SONG_FILE as the
|
||||
# key in the database so everything matches up when querying.
|
||||
data_key
|
||||
if data_key != KEYS.SONG_FILE_PERMANENT
|
||||
else KEYS.SONG_FILE
|
||||
),
|
||||
parameter=param,
|
||||
defaults={
|
||||
"cache_key": data_key,
|
||||
"parameter": param,
|
||||
"last_ingestion_time": now,
|
||||
# If it's partial data, then set it to be invalid so it will only be
|
||||
# used in the event that the ground truth adapter can't service the
|
||||
# request.
|
||||
"valid": not partial,
|
||||
},
|
||||
)
|
||||
if not cache_info_created:
|
||||
cache_info.valid = cache_info.valid or not partial
|
||||
cache_info.last_ingestion_time = now
|
||||
cache_info.save()
|
||||
|
||||
if data_key == KEYS.ALBUM:
|
||||
album = cast(API.Album, data)
|
||||
album_id = album.id or f"invalid:{self._strhash(album.name)}"
|
||||
album_data = {
|
||||
"id": album_id,
|
||||
**getattrs(
|
||||
album,
|
||||
[
|
||||
"name",
|
||||
"created",
|
||||
"duration",
|
||||
"play_count",
|
||||
"song_count",
|
||||
"starred",
|
||||
"year",
|
||||
],
|
||||
),
|
||||
"genre": (
|
||||
self._do_ingest_new_data(KEYS.GENRE, None, g)
|
||||
if (g := album.genre)
|
||||
else None
|
||||
),
|
||||
"artist": (
|
||||
self._do_ingest_new_data(KEYS.ARTIST, ar.id, ar, partial=True)
|
||||
if (ar := album.artist)
|
||||
else None
|
||||
),
|
||||
"_songs": (
|
||||
[
|
||||
self._do_ingest_new_data(KEYS.SONG, s.id, s)
|
||||
for s in album.songs or []
|
||||
]
|
||||
if not partial
|
||||
else None
|
||||
),
|
||||
"_cover_art": (
|
||||
self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, album.cover_art, data=None
|
||||
)
|
||||
if album.cover_art
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
db_album, created = models.Album.get_or_create(
|
||||
id=album_id, defaults=album_data
|
||||
)
|
||||
|
||||
if not created:
|
||||
setattrs(db_album, album_data)
|
||||
db_album.save()
|
||||
|
||||
return_val = db_album
|
||||
|
||||
elif data_key == KEYS.ALBUMS:
|
||||
albums = [
|
||||
self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
|
||||
for a in data
|
||||
]
|
||||
album_query_result, created = models.AlbumQueryResult.get_or_create(
|
||||
query_hash=param, defaults={"query_hash": param, "albums": albums}
|
||||
)
|
||||
|
||||
if not created:
|
||||
album_query_result.albums = albums
|
||||
try:
|
||||
album_query_result.save()
|
||||
except ValueError:
|
||||
# No save necessary.
|
||||
pass
|
||||
|
||||
elif data_key == KEYS.ARTIST:
|
||||
# Ingest similar artists.
|
||||
artist = cast(API.Artist, data)
|
||||
if artist.similar_artists:
|
||||
models.SimilarArtist.delete().where(
|
||||
models.SimilarArtist.similar_artist.not_in(
|
||||
[sa.id for sa in artist.similar_artists or []]
|
||||
),
|
||||
models.Artist == artist.id,
|
||||
).execute()
|
||||
models.SimilarArtist.insert_many(
|
||||
[
|
||||
{"artist": artist.id, "similar_artist": a.id, "order": i}
|
||||
for i, a in enumerate(artist.similar_artists or [])
|
||||
]
|
||||
).on_conflict_replace().execute()
|
||||
|
||||
artist_id = artist.id or f"invalid:{self._strhash(artist.name)}"
|
||||
artist_data = {
|
||||
"id": artist_id,
|
||||
**getattrs(
|
||||
artist,
|
||||
[
|
||||
"name",
|
||||
"album_count",
|
||||
"starred",
|
||||
"biography",
|
||||
"music_brainz_id",
|
||||
"last_fm_url",
|
||||
],
|
||||
),
|
||||
"albums": [
|
||||
self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
|
||||
for a in artist.albums or []
|
||||
],
|
||||
"_artist_image_url": (
|
||||
self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, artist.artist_image_url, data=None
|
||||
)
|
||||
if artist.artist_image_url
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
db_artist, created = models.Artist.get_or_create(
|
||||
id=artist_id, defaults=artist_data
|
||||
)
|
||||
|
||||
if not created:
|
||||
setattrs(db_artist, artist_data)
|
||||
db_artist.save()
|
||||
|
||||
return_val = db_artist
|
||||
|
||||
elif data_key == KEYS.ARTISTS:
|
||||
for a in data:
|
||||
self._do_ingest_new_data(KEYS.ARTIST, a.id, a, partial=True)
|
||||
models.Artist.delete().where(
|
||||
models.Artist.id.not_in([a.id for a in data])
|
||||
& ~models.Artist.id.startswith("invalid")
|
||||
).execute()
|
||||
|
||||
elif data_key == KEYS.COVER_ART_FILE:
|
||||
cache_info.file_id = param
|
||||
|
||||
if data is not None:
|
||||
file_hash = compute_file_hash(data)
|
||||
cache_info.file_hash = file_hash
|
||||
|
||||
# Copy the actual cover art file
|
||||
shutil.copy(str(data), str(self.cover_art_dir.joinpath(file_hash)))
|
||||
|
||||
elif data_key == KEYS.DIRECTORY:
|
||||
api_directory = cast(API.Directory, data)
|
||||
directory_data: Dict[str, Any] = getattrs(
|
||||
api_directory, ["id", "name", "parent_id"]
|
||||
)
|
||||
|
||||
if not partial:
|
||||
directory_data["directory_children"] = []
|
||||
directory_data["song_children"] = []
|
||||
@@ -514,14 +689,15 @@ class FilesystemAdapter(CachingAdapter):
|
||||
setattrs(directory, directory_data)
|
||||
directory.save()
|
||||
|
||||
return directory
|
||||
return_val = directory
|
||||
|
||||
def ingest_genre_data(api_genre: API.Genre) -> models.Genre:
|
||||
genre_data = {
|
||||
"name": api_genre.name,
|
||||
"song_count": getattr(api_genre, "song_count", None),
|
||||
"album_count": getattr(api_genre, "album_count", None),
|
||||
}
|
||||
elif data_key == KEYS.GENRES:
|
||||
for g in data:
|
||||
self._do_ingest_new_data(KEYS.GENRE, None, g)
|
||||
|
||||
elif data_key == KEYS.GENRE:
|
||||
api_genre = cast(API.Genre, data)
|
||||
genre_data = getattrs(api_genre, ["name", "song_count", "album_count"])
|
||||
genre, created = models.Genre.get_or_create(
|
||||
name=api_genre.name, defaults=genre_data
|
||||
)
|
||||
@@ -530,216 +706,7 @@ class FilesystemAdapter(CachingAdapter):
|
||||
setattrs(genre, genre_data)
|
||||
genre.save()
|
||||
|
||||
return genre
|
||||
|
||||
def ingest_album_data(
|
||||
api_album: API.Album, exclude_artist: bool = False
|
||||
) -> models.Album:
|
||||
album_id = api_album.id or f"invalid:{self._strhash(api_album.name)}"
|
||||
album_data = {
|
||||
"id": album_id,
|
||||
"name": api_album.name,
|
||||
"created": getattr(api_album, "created", None),
|
||||
"duration": getattr(api_album, "duration", None),
|
||||
"play_count": getattr(api_album, "play_count", None),
|
||||
"song_count": getattr(api_album, "song_count", None),
|
||||
"starred": getattr(api_album, "starred", None),
|
||||
"year": getattr(api_album, "year", None),
|
||||
"genre": ingest_genre_data(g) if (g := api_album.genre) else None,
|
||||
"artist": ingest_artist_data(ar) if (ar := api_album.artist) else None,
|
||||
"_songs": [
|
||||
ingest_song_data(s, fill_album=False) for s in api_album.songs or []
|
||||
],
|
||||
"_cover_art": self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, api_album.cover_art, data=None,
|
||||
)
|
||||
if api_album.cover_art
|
||||
else None,
|
||||
}
|
||||
|
||||
album, created = models.Album.get_or_create(
|
||||
id=api_album.id, defaults=album_data
|
||||
)
|
||||
|
||||
if not created:
|
||||
setattrs(album, album_data)
|
||||
album.save()
|
||||
|
||||
return album
|
||||
|
||||
def ingest_artist_data(api_artist: API.Artist) -> models.Artist:
|
||||
# Ingest similar artists.
|
||||
if api_artist.similar_artists:
|
||||
models.SimilarArtist.delete().where(
|
||||
models.SimilarArtist.similar_artist.not_in(
|
||||
[sa.id for sa in api_artist.similar_artists or []]
|
||||
),
|
||||
models.Artist == api_artist.id,
|
||||
).execute()
|
||||
models.SimilarArtist.insert_many(
|
||||
[
|
||||
{"artist": api_artist.id, "similar_artist": a.id, "order": i}
|
||||
for i, a in enumerate(api_artist.similar_artists or [])
|
||||
]
|
||||
).on_conflict_replace().execute()
|
||||
|
||||
artist_id = api_artist.id or f"invalid:{self._strhash(api_artist.name)}"
|
||||
artist_data = {
|
||||
"id": artist_id,
|
||||
"name": api_artist.name,
|
||||
"album_count": getattr(api_artist, "album_count", None),
|
||||
"starred": getattr(api_artist, "starred", None),
|
||||
"biography": getattr(api_artist, "biography", None),
|
||||
"music_brainz_id": getattr(api_artist, "music_brainz_id", None),
|
||||
"last_fm_url": getattr(api_artist, "last_fm_url", None),
|
||||
"albums": [
|
||||
ingest_album_data(a, exclude_artist=True)
|
||||
for a in api_artist.albums or []
|
||||
],
|
||||
"_artist_image_url": self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, api_artist.artist_image_url, data=None,
|
||||
)
|
||||
if api_artist.artist_image_url
|
||||
else None,
|
||||
}
|
||||
|
||||
artist, created = models.Artist.get_or_create(
|
||||
id=artist_id, defaults=artist_data
|
||||
)
|
||||
|
||||
if not created:
|
||||
setattrs(artist, artist_data)
|
||||
artist.save()
|
||||
|
||||
return artist
|
||||
|
||||
def ingest_song_data(
|
||||
api_song: API.Song, fill_album: bool = True
|
||||
) -> models.Song:
|
||||
song_data = {
|
||||
"id": api_song.id,
|
||||
"title": api_song.title,
|
||||
"track": getattr(api_song, "track", None),
|
||||
"year": getattr(api_song, "year", None),
|
||||
"duration": getattr(api_song, "duration", None),
|
||||
"parent_id": api_song.parent_id,
|
||||
# Ingest the FKs.
|
||||
"genre": ingest_genre_data(g) if (g := api_song.genre) else None,
|
||||
"artist": ingest_artist_data(ar) if (ar := api_song.artist) else None,
|
||||
"album": ingest_album_data(al) if (al := api_song.album) else None,
|
||||
"_cover_art": self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, api_song.cover_art, data=None,
|
||||
)
|
||||
if api_song.cover_art
|
||||
else None,
|
||||
"file": self._do_ingest_new_data(
|
||||
KEYS.SONG_FILE,
|
||||
api_song.id,
|
||||
data=(api_song.path, None, api_song.size),
|
||||
)
|
||||
if api_song.path
|
||||
else None,
|
||||
}
|
||||
|
||||
song, created = models.Song.get_or_create(
|
||||
id=song_data["id"], defaults=song_data
|
||||
)
|
||||
|
||||
if not created:
|
||||
setattrs(song, song_data)
|
||||
song.save()
|
||||
|
||||
return song
|
||||
|
||||
def ingest_playlist(
|
||||
api_playlist: Union[API.Playlist, API.Playlist]
|
||||
) -> models.Playlist:
|
||||
playlist_data = {
|
||||
"id": api_playlist.id,
|
||||
"name": api_playlist.name,
|
||||
"song_count": api_playlist.song_count,
|
||||
"duration": api_playlist.duration,
|
||||
"created": getattr(api_playlist, "created", None),
|
||||
"changed": getattr(api_playlist, "changed", None),
|
||||
"comment": getattr(api_playlist, "comment", None),
|
||||
"owner": getattr(api_playlist, "owner", None),
|
||||
"public": getattr(api_playlist, "public", None),
|
||||
"_songs": [
|
||||
self._do_ingest_new_data(KEYS.SONG, s.id, s)
|
||||
for s in api_playlist.songs
|
||||
],
|
||||
"_cover_art": self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, api_playlist.cover_art, None
|
||||
)
|
||||
if api_playlist.cover_art
|
||||
else None,
|
||||
}
|
||||
|
||||
playlist, playlist_created = models.Playlist.get_or_create(
|
||||
id=playlist_data["id"], defaults=playlist_data
|
||||
)
|
||||
|
||||
# Update the values if the playlist already existed.
|
||||
if not playlist_created:
|
||||
setattrs(playlist, playlist_data)
|
||||
playlist.save()
|
||||
|
||||
return playlist
|
||||
|
||||
def compute_file_hash(filename: str) -> str:
|
||||
file_hash = hashlib.sha1()
|
||||
with open(filename, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
file_hash.update(chunk)
|
||||
|
||||
return file_hash.hexdigest()
|
||||
|
||||
return_val = None
|
||||
|
||||
if data_key == KEYS.ALBUM:
|
||||
return_val = ingest_album_data(data)
|
||||
|
||||
elif data_key == KEYS.ALBUMS:
|
||||
albums = [ingest_album_data(a) for a in data]
|
||||
album_query_result, created = models.AlbumQueryResult.get_or_create(
|
||||
query_hash=param, defaults={"query_hash": param, "albums": albums}
|
||||
)
|
||||
|
||||
if not created:
|
||||
album_query_result.albums = albums
|
||||
try:
|
||||
album_query_result.save()
|
||||
except ValueError:
|
||||
# No save necessary.
|
||||
pass
|
||||
|
||||
elif data_key == KEYS.ARTIST:
|
||||
return_val = ingest_artist_data(data)
|
||||
|
||||
elif data_key == KEYS.ARTISTS:
|
||||
for a in data:
|
||||
ingest_artist_data(a)
|
||||
models.Artist.delete().where(
|
||||
models.Artist.id.not_in([a.id for a in data])
|
||||
& ~models.Artist.id.startswith("invalid")
|
||||
).execute()
|
||||
|
||||
elif data_key == KEYS.COVER_ART_FILE:
|
||||
cache_info_extra["file_id"] = param
|
||||
|
||||
if data is not None:
|
||||
file_hash = compute_file_hash(data)
|
||||
cache_info_extra["file_hash"] = file_hash
|
||||
|
||||
# Copy the actual cover art file
|
||||
shutil.copy(str(data), str(self.cover_art_dir.joinpath(file_hash)))
|
||||
|
||||
elif data_key == KEYS.DIRECTORY:
|
||||
return_val = ingest_directory_data(data)
|
||||
|
||||
elif data_key == KEYS.GENRES:
|
||||
for g in data:
|
||||
ingest_genre_data(g)
|
||||
return_val = genre
|
||||
|
||||
elif data_key == KEYS.IGNORED_ARTICLES:
|
||||
models.IgnoredArticle.insert_many(
|
||||
@@ -750,12 +717,53 @@ class FilesystemAdapter(CachingAdapter):
|
||||
).execute()
|
||||
|
||||
elif data_key == KEYS.PLAYLIST_DETAILS:
|
||||
return_val = ingest_playlist(data)
|
||||
api_playlist = cast(API.Playlist, data)
|
||||
playlist_data: Dict[str, Any] = {
|
||||
**getattrs(
|
||||
api_playlist,
|
||||
[
|
||||
"id",
|
||||
"name",
|
||||
"song_count",
|
||||
"duration",
|
||||
"created",
|
||||
"changed",
|
||||
"comment",
|
||||
"owner",
|
||||
"public",
|
||||
],
|
||||
),
|
||||
"_cover_art": (
|
||||
self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, api_playlist.cover_art, None
|
||||
)
|
||||
if api_playlist.cover_art
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
if not partial:
|
||||
# If it's partial, then don't ingest the songs.
|
||||
playlist_data["_songs"] = [
|
||||
self._do_ingest_new_data(KEYS.SONG, s.id, s)
|
||||
for s in api_playlist.songs
|
||||
]
|
||||
|
||||
playlist, playlist_created = models.Playlist.get_or_create(
|
||||
id=playlist_data["id"], defaults=playlist_data
|
||||
)
|
||||
|
||||
# Update the values if the playlist already existed.
|
||||
if not playlist_created:
|
||||
setattrs(playlist, playlist_data)
|
||||
playlist.save()
|
||||
|
||||
return_val = playlist
|
||||
|
||||
elif data_key == KEYS.PLAYLISTS:
|
||||
self._playlists = None
|
||||
for p in data:
|
||||
ingest_playlist(p)
|
||||
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, p.id, p, partial=True)
|
||||
models.Playlist.delete().where(
|
||||
models.Playlist.id.not_in([p.id for p in data])
|
||||
).execute()
|
||||
@@ -763,49 +771,70 @@ class FilesystemAdapter(CachingAdapter):
|
||||
elif data_key == KEYS.SEARCH_RESULTS:
|
||||
data = cast(API.SearchResult, data)
|
||||
for a in data._artists.values():
|
||||
ingest_artist_data(a)
|
||||
self._do_ingest_new_data(KEYS.ARTIST, a.id, a, partial=True)
|
||||
|
||||
for a in data._albums.values():
|
||||
ingest_album_data(a)
|
||||
self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
|
||||
|
||||
for s in data._songs.values():
|
||||
ingest_song_data(s)
|
||||
self._do_ingest_new_data(KEYS.SONG, s.id, s, partial=True)
|
||||
|
||||
for p in data._playlists.values():
|
||||
ingest_playlist(p)
|
||||
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, p.id, p, partial=True)
|
||||
|
||||
elif data_key == KEYS.SONG:
|
||||
return_val = ingest_song_data(data)
|
||||
api_song = cast(API.Song, data)
|
||||
song_data = getattrs(
|
||||
api_song, ["id", "title", "track", "year", "duration", "parent_id"]
|
||||
)
|
||||
if not partial:
|
||||
song_data["genre"] = (
|
||||
self._do_ingest_new_data(KEYS.GENRE, None, g)
|
||||
if (g := api_song.genre)
|
||||
else None
|
||||
)
|
||||
song_data["artist"] = (
|
||||
self._do_ingest_new_data(KEYS.ARTIST, ar.id, ar, partial=True)
|
||||
if (ar := api_song.artist) and not partial
|
||||
else None
|
||||
)
|
||||
song_data["album"] = (
|
||||
self._do_ingest_new_data(KEYS.ALBUM, al.id, al, partial=True)
|
||||
if (al := api_song.album)
|
||||
else None
|
||||
)
|
||||
song_data["_cover_art"] = (
|
||||
self._do_ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, api_song.cover_art, data=None,
|
||||
)
|
||||
if api_song.cover_art
|
||||
else None
|
||||
)
|
||||
song_data["file"] = (
|
||||
self._do_ingest_new_data(
|
||||
KEYS.SONG_FILE,
|
||||
api_song.id,
|
||||
data=(api_song.path, None, api_song.size),
|
||||
)
|
||||
if api_song.path
|
||||
else None
|
||||
)
|
||||
|
||||
song, created = models.Song.get_or_create(
|
||||
id=song_data["id"], defaults=song_data
|
||||
)
|
||||
|
||||
if not created:
|
||||
setattrs(song, song_data)
|
||||
song.save()
|
||||
|
||||
return_val = song
|
||||
|
||||
elif data_key == KEYS.SONG_FILE:
|
||||
cache_info_extra["file_id"] = param
|
||||
cache_info.file_id = param
|
||||
|
||||
elif data_key == KEYS.SONG_FILE_PERMANENT:
|
||||
data_key = KEYS.SONG_FILE
|
||||
cache_info_extra["cache_permanently"] = True
|
||||
|
||||
# Set the cache info.
|
||||
now = datetime.now()
|
||||
cache_info, cache_info_created = models.CacheInfo.get_or_create(
|
||||
cache_key=data_key,
|
||||
parameter=param,
|
||||
defaults={
|
||||
"cache_key": data_key,
|
||||
"parameter": param,
|
||||
"last_ingestion_time": now,
|
||||
# If it's partial data, then set it to be invalid so it will only be
|
||||
# used in the event that the ground truth adapter can't service the
|
||||
# request.
|
||||
"valid": not partial,
|
||||
**cache_info_extra,
|
||||
},
|
||||
)
|
||||
if not cache_info_created:
|
||||
cache_info.last_ingestion_time = now
|
||||
cache_info.valid = not partial
|
||||
for k, v in cache_info_extra.items():
|
||||
setattr(cache_info, k, v)
|
||||
cache_info.save()
|
||||
cache_info.cache_permanently = True
|
||||
|
||||
# Special handling for Song
|
||||
if data_key == KEYS.SONG_FILE and data:
|
||||
@@ -826,8 +855,7 @@ class FilesystemAdapter(CachingAdapter):
|
||||
filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(str(buffer_filename), str(filename))
|
||||
|
||||
cache_info.save()
|
||||
|
||||
cache_info.save()
|
||||
return return_val if return_val is not None else cache_info
|
||||
|
||||
def _do_invalidate_data(
|
||||
@@ -838,33 +866,29 @@ class FilesystemAdapter(CachingAdapter):
|
||||
models.CacheInfo.cache_key == data_key, models.CacheInfo.parameter == param
|
||||
).execute()
|
||||
|
||||
cover_art_cache_key = CachingAdapter.CachedDataKey.COVER_ART_FILE
|
||||
|
||||
if data_key == CachingAdapter.CachedDataKey.ALBUM:
|
||||
album = models.Album.get_or_none(models.Album.id == param)
|
||||
if album:
|
||||
self._do_invalidate_data(cover_art_cache_key, album.cover_art)
|
||||
|
||||
elif data_key == CachingAdapter.CachedDataKey.ARTIST:
|
||||
if data_key == KEYS.ALBUM:
|
||||
# Invalidate the corresponding cover art.
|
||||
if album := models.Album.get_or_none(models.Album.id == param):
|
||||
self._do_invalidate_data(KEYS.COVER_ART_FILE, album.cover_art)
|
||||
|
||||
elif data_key == KEYS.ARTIST:
|
||||
# Invalidate the corresponding cover art and albums.
|
||||
if artist := models.Artist.get_or_none(models.Artist.id == param):
|
||||
self._do_invalidate_data(cover_art_cache_key, artist.artist_image_url)
|
||||
self._do_invalidate_data(KEYS.COVER_ART_FILE, artist.artist_image_url)
|
||||
for album in artist.albums or []:
|
||||
self._do_invalidate_data(
|
||||
CachingAdapter.CachedDataKey.ALBUM, album.id
|
||||
)
|
||||
|
||||
elif data_key == CachingAdapter.CachedDataKey.PLAYLIST_DETAILS:
|
||||
elif data_key == KEYS.PLAYLIST_DETAILS:
|
||||
# Invalidate the corresponding cover art.
|
||||
if playlist := models.Playlist.get_or_none(models.Playlist.id == param):
|
||||
self._do_invalidate_data(cover_art_cache_key, playlist.cover_art)
|
||||
self._do_invalidate_data(KEYS.COVER_ART_FILE, playlist.cover_art)
|
||||
|
||||
elif data_key == CachingAdapter.CachedDataKey.SONG_FILE:
|
||||
elif data_key == KEYS.SONG_FILE:
|
||||
# Invalidate the corresponding cover art.
|
||||
if song := models.Song.get_or_none(models.Song.id == param):
|
||||
self._do_invalidate_data(
|
||||
CachingAdapter.CachedDataKey.COVER_ART_FILE, song.cover_art
|
||||
)
|
||||
self._do_invalidate_data(KEYS.COVER_ART_FILE, song.cover_art)
|
||||
|
||||
def _do_delete_data(
|
||||
self, data_key: CachingAdapter.CachedDataKey, param: Optional[str]
|
||||
@@ -874,45 +898,41 @@ class FilesystemAdapter(CachingAdapter):
|
||||
models.CacheInfo.cache_key == data_key, models.CacheInfo.parameter == param,
|
||||
)
|
||||
|
||||
if data_key == CachingAdapter.CachedDataKey.COVER_ART_FILE:
|
||||
if data_key == KEYS.COVER_ART_FILE:
|
||||
if cache_info:
|
||||
self.cover_art_dir.joinpath(str(cache_info.file_hash)).unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
|
||||
elif data_key == CachingAdapter.CachedDataKey.PLAYLIST_DETAILS:
|
||||
elif data_key == KEYS.PLAYLIST_DETAILS:
|
||||
# Delete the playlist and corresponding cover art.
|
||||
if playlist := models.Playlist.get_or_none(models.Playlist.id == param):
|
||||
if cover_art := playlist.cover_art:
|
||||
self._do_delete_data(
|
||||
CachingAdapter.CachedDataKey.COVER_ART_FILE, cover_art
|
||||
)
|
||||
self._do_delete_data(KEYS.COVER_ART_FILE, cover_art)
|
||||
|
||||
playlist.delete_instance()
|
||||
|
||||
elif data_key == CachingAdapter.CachedDataKey.SONG_FILE:
|
||||
elif data_key == KEYS.SONG_FILE:
|
||||
if cache_info:
|
||||
self._compute_song_filename(cache_info).unlink(missing_ok=True)
|
||||
|
||||
elif data_key == CachingAdapter.CachedDataKey.ALL_SONGS:
|
||||
elif data_key == KEYS.ALL_SONGS:
|
||||
shutil.rmtree(str(self.music_dir))
|
||||
shutil.rmtree(str(self.cover_art_dir))
|
||||
self.music_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cover_art_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
models.CacheInfo.update({"valid": False}).where(
|
||||
models.CacheInfo.cache_key == CachingAdapter.CachedDataKey.SONG_FILE
|
||||
models.CacheInfo.cache_key == KEYS.SONG_FILE
|
||||
).execute()
|
||||
models.CacheInfo.update({"valid": False}).where(
|
||||
models.CacheInfo.cache_key
|
||||
== CachingAdapter.CachedDataKey.COVER_ART_FILE
|
||||
models.CacheInfo.cache_key == KEYS.COVER_ART_FILE
|
||||
).execute()
|
||||
|
||||
elif data_key == CachingAdapter.CachedDataKey.EVERYTHING:
|
||||
self._do_delete_data(CachingAdapter.CachedDataKey.ALL_SONGS, None)
|
||||
elif data_key == KEYS.EVERYTHING:
|
||||
self._do_delete_data(KEYS.ALL_SONGS, None)
|
||||
for table in models.ALL_TABLES:
|
||||
table.truncate_table()
|
||||
|
||||
if cache_info:
|
||||
cache_info.valid = False
|
||||
cache_info.save()
|
||||
cache_info.delete_instance()
|
||||
|
@@ -24,6 +24,7 @@ from typing import (
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
import semver
|
||||
from gi.repository import Gtk
|
||||
|
||||
from .api_objects import Directory, Response
|
||||
@@ -201,7 +202,7 @@ class SubsonicAdapter(Adapter):
|
||||
self.hostname = "https://" + self.hostname
|
||||
|
||||
self.username = config["username"]
|
||||
self.password = config.get_secret("password")
|
||||
self.password = cast(str, config.get_secret("password"))
|
||||
self.verify_cert = config["verify_cert"]
|
||||
|
||||
self.is_shutting_down = False
|
||||
@@ -209,6 +210,7 @@ class SubsonicAdapter(Adapter):
|
||||
# TODO (#112): support XML?
|
||||
|
||||
_ping_process: Optional[multiprocessing.Process] = None
|
||||
_version = multiprocessing.Array("c", 20)
|
||||
_offline_mode = False
|
||||
|
||||
def initial_sync(self):
|
||||
@@ -262,27 +264,40 @@ class SubsonicAdapter(Adapter):
|
||||
def ping_status(self) -> bool:
|
||||
return self._server_available.value
|
||||
|
||||
# TODO (#199) make these way smarter
|
||||
can_get_playlists = True
|
||||
can_get_playlist_details = True
|
||||
can_create_playlist = True
|
||||
can_update_playlist = True
|
||||
can_delete_playlist = True
|
||||
can_get_cover_art_uri = True
|
||||
can_get_song_uri = True
|
||||
can_stream = True
|
||||
can_get_song_details = True
|
||||
can_scrobble_song = True
|
||||
can_get_artists = True
|
||||
can_get_artist = True
|
||||
can_get_ignored_articles = True
|
||||
can_get_albums = True
|
||||
can_get_album = True
|
||||
can_get_albums = True
|
||||
can_get_artist = True
|
||||
can_get_artists = True
|
||||
can_get_cover_art_uri = True
|
||||
can_get_directory = True
|
||||
can_get_genres = True
|
||||
can_get_play_queue = True
|
||||
can_save_play_queue = True
|
||||
can_get_ignored_articles = True
|
||||
can_get_playlist_details = True
|
||||
can_get_playlists = True
|
||||
can_get_song_details = True
|
||||
can_get_song_uri = True
|
||||
can_scrobble_song = True
|
||||
can_search = True
|
||||
can_stream = True
|
||||
can_update_playlist = True
|
||||
|
||||
def version_at_least(self, version: str) -> bool:
|
||||
if not self._version.value:
|
||||
return False
|
||||
return semver.VersionInfo.parse(self._version.value.decode()) >= version
|
||||
|
||||
@property
|
||||
def can_get_genres(self) -> bool:
|
||||
return self.version_at_least("1.9.0")
|
||||
|
||||
@property
|
||||
def can_get_play_queue(self) -> bool:
|
||||
return self.version_at_least("1.12.0")
|
||||
|
||||
@property
|
||||
def can_save_play_queue(self) -> bool:
|
||||
return self.version_at_least("1.12.0")
|
||||
|
||||
_schemes = None
|
||||
|
||||
@@ -412,12 +427,14 @@ class SubsonicAdapter(Adapter):
|
||||
if not subsonic_response:
|
||||
raise ServerError(500, f"{url} returned invalid JSON.")
|
||||
|
||||
if subsonic_response["status"] == "failed":
|
||||
if subsonic_response["status"] != "ok":
|
||||
raise ServerError(
|
||||
subsonic_response["error"].get("code"),
|
||||
subsonic_response["error"].get("message"),
|
||||
)
|
||||
|
||||
self._version.value = subsonic_response["version"].encode()
|
||||
|
||||
logging.debug(f"Response from {url}: {subsonic_response}")
|
||||
return Response.from_dict(subsonic_response)
|
||||
|
||||
@@ -538,9 +555,14 @@ class SubsonicAdapter(Adapter):
|
||||
def get_artist(self, artist_id: str) -> API.Artist:
|
||||
artist = self._get_json(self._make_url("getArtist"), id=artist_id).artist
|
||||
assert artist, f"Error getting artist {artist_id}"
|
||||
artist.augment_with_artist_info(
|
||||
self._get_json(self._make_url("getArtistInfo2"), id=artist_id).artist_info
|
||||
)
|
||||
if self.version_at_least("1.11.0"):
|
||||
try:
|
||||
artist_info = self._get_json(
|
||||
self._make_url("getArtistInfo2"), id=artist_id
|
||||
)
|
||||
artist.augment_with_artist_info(artist_info.artist_info)
|
||||
except Exception:
|
||||
pass
|
||||
return artist
|
||||
|
||||
def get_ignored_articles(self) -> Set[str]:
|
||||
@@ -651,6 +673,7 @@ class SubsonicAdapter(Adapter):
|
||||
self._get(
|
||||
self._make_url("savePlayQueue"),
|
||||
id=song_ids,
|
||||
timeout=2,
|
||||
current=song_ids[current_song_index]
|
||||
if current_song_index is not None
|
||||
else None,
|
||||
|
@@ -29,11 +29,15 @@ encoder_functions = {
|
||||
|
||||
for type_, translation_function in decoder_functions.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_] # type: ignore
|
||||
] = translation_function
|
||||
|
||||
for type_, translation_function in encoder_functions.items():
|
||||
dataclasses_json.cfg.global_config.encoders[type_] = translation_function
|
||||
dataclasses_json.cfg.global_config.encoders[Optional[type_]] = translation_function
|
||||
dataclasses_json.cfg.global_config.encoders[
|
||||
Optional[type_] # type: ignore
|
||||
] = translation_function
|
||||
|
||||
|
||||
@dataclass_json(letter_case=LetterCase.CAMEL)
|
||||
@@ -99,7 +103,8 @@ class ArtistAndArtistInfo(SublimeAPI.Artist):
|
||||
last_fm_url: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.album_count = self.album_count or len(self.albums)
|
||||
if not self.album_count and len(self.albums) > 0:
|
||||
self.album_count = len(self.albums)
|
||||
if not self.artist_image_url:
|
||||
self.artist_image_url = self.cover_art
|
||||
|
||||
|
240
sublime/app.py
240
sublime/app.py
@@ -42,7 +42,7 @@ from .adapters import (
|
||||
from .adapters.api_objects import Playlist, PlayQueue, Song
|
||||
from .config import AppConfiguration, ProviderConfiguration
|
||||
from .dbus import dbus_propagate, DBusManager
|
||||
from .players import ChromecastPlayer, MPVPlayer, Player, PlayerEvent
|
||||
from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
|
||||
from .ui.configure_provider import ConfigureProviderDialog
|
||||
from .ui.main import MainWindow
|
||||
from .ui.state import RepeatType, UIState
|
||||
@@ -60,7 +60,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
self.connect("shutdown", self.on_app_shutdown)
|
||||
|
||||
player: Optional[Player] = None
|
||||
player_manager: Optional[PlayerManager] = None
|
||||
exiting: bool = False
|
||||
|
||||
def do_startup(self):
|
||||
@@ -100,6 +100,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
|
||||
|
||||
add_action("go-online", self.on_go_online)
|
||||
add_action("refresh-devices", self.on_refresh_devices)
|
||||
add_action(
|
||||
"refresh-window", lambda *a: self.on_refresh_window(None, {}, True),
|
||||
)
|
||||
@@ -188,7 +189,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.loading_state = False
|
||||
self.should_scrobble_song = False
|
||||
|
||||
def time_observer(value: Optional[float]):
|
||||
def on_timepos_change(value: Optional[float]):
|
||||
if (
|
||||
self.loading_state
|
||||
or not self.window
|
||||
@@ -234,19 +235,21 @@ class SublimeMusicApp(Gtk.Application):
|
||||
GLib.idle_add(self.on_next_track)
|
||||
|
||||
def on_player_event(event: PlayerEvent):
|
||||
if event.type == PlayerEvent.Type.PLAY_STATE_CHANGE:
|
||||
if event.type == PlayerEvent.EventType.PLAY_STATE_CHANGE:
|
||||
assert event.playing is not None
|
||||
self.app_config.state.playing = event.playing
|
||||
if self.dbus_manager:
|
||||
self.dbus_manager.property_diff()
|
||||
self.update_window()
|
||||
elif event.type == PlayerEvent.Type.VOLUME_CHANGE:
|
||||
|
||||
elif event.type == PlayerEvent.EventType.VOLUME_CHANGE:
|
||||
assert event.volume is not None
|
||||
self.app_config.state.volume = event.volume
|
||||
if self.dbus_manager:
|
||||
self.dbus_manager.property_diff()
|
||||
self.update_window()
|
||||
elif event.type == PlayerEvent.Type.STREAM_CACHE_PROGRESS_CHANGE:
|
||||
|
||||
elif event.type == PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE:
|
||||
if (
|
||||
self.loading_state
|
||||
or not self.window
|
||||
@@ -264,36 +267,67 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.song_stream_cache_progress,
|
||||
)
|
||||
|
||||
self.mpv_player = MPVPlayer(
|
||||
time_observer, on_track_end, on_player_event, self.app_config,
|
||||
elif event.type == PlayerEvent.EventType.DISCONNECT:
|
||||
assert self.player_manager
|
||||
self.app_config.state.current_device = "this device"
|
||||
self.player_manager.set_current_device_id(
|
||||
self.app_config.state.current_device
|
||||
)
|
||||
self.player_manager.set_volume(self.app_config.state.volume)
|
||||
self.update_window()
|
||||
|
||||
def player_device_change_callback(event: PlayerDeviceEvent):
|
||||
assert self.player_manager
|
||||
state_device = self.app_config.state.current_device
|
||||
|
||||
if event.delta == PlayerDeviceEvent.Delta.ADD:
|
||||
# If the device added is the one that's supposed to be active, activate
|
||||
# it and set the volume.
|
||||
if event.id == state_device:
|
||||
self.player_manager.set_current_device_id(
|
||||
self.app_config.state.current_device
|
||||
)
|
||||
self.player_manager.set_volume(self.app_config.state.volume)
|
||||
self.app_config.state.connected_device_name = event.name
|
||||
self.app_config.state.connecting_to_device = False
|
||||
self.app_config.state.available_players[event.player_type].add(
|
||||
(event.id, event.name)
|
||||
)
|
||||
|
||||
elif event.delta == PlayerDeviceEvent.Delta.REMOVE:
|
||||
if state_device == event.id:
|
||||
self.player_manager.pause()
|
||||
self.app_config.state.available_players[event.player_type].remove(
|
||||
(event.id, event.name)
|
||||
)
|
||||
|
||||
self.update_window()
|
||||
|
||||
self.app_config.state.connecting_to_device = True
|
||||
|
||||
def check_if_connected():
|
||||
if not self.app_config.state.connecting_to_device:
|
||||
return
|
||||
self.app_config.state.current_device = "this device"
|
||||
self.app_config.state.connecting_to_device = False
|
||||
self.player_manager.set_current_device_id(
|
||||
self.app_config.state.current_device
|
||||
)
|
||||
self.update_window()
|
||||
|
||||
self.player_manager = PlayerManager(
|
||||
on_timepos_change,
|
||||
on_track_end,
|
||||
on_player_event,
|
||||
lambda *a: GLib.idle_add(player_device_change_callback, *a),
|
||||
self.app_config.player_config,
|
||||
)
|
||||
self.chromecast_player = ChromecastPlayer(
|
||||
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":
|
||||
# TODO (#120) attempt to connect to the previously connected device
|
||||
pass
|
||||
|
||||
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
|
||||
GLib.timeout_add(10000, check_if_connected)
|
||||
|
||||
# Update after Adapter Initial Sync
|
||||
inital_sync_result = AdapterManager.initial_sync()
|
||||
inital_sync_result.add_done_callback(lambda _: self.update_window())
|
||||
|
||||
# Start a loop for periodically updating the window every 10 seconds.
|
||||
def periodic_update():
|
||||
if self.exiting:
|
||||
return
|
||||
self.update_window()
|
||||
GLib.timeout_add(10000, periodic_update)
|
||||
|
||||
GLib.timeout_add(10000, periodic_update)
|
||||
|
||||
# Prompt to load the play queue from the server.
|
||||
if AdapterManager.can_get_play_queue():
|
||||
self.update_play_state_from_server(prompt_confirm=True)
|
||||
@@ -308,7 +342,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
connection,
|
||||
self.on_dbus_method_call,
|
||||
self.on_dbus_set_property,
|
||||
lambda: (self.app_config, self.player),
|
||||
lambda: (self.app_config, self.player_manager),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -322,13 +356,12 @@ class SublimeMusicApp(Gtk.Application):
|
||||
params: GLib.Variant,
|
||||
invocation: Gio.DBusMethodInvocation,
|
||||
):
|
||||
second_microsecond_conversion = 1000000
|
||||
|
||||
def seek_fn(offset: float):
|
||||
if not self.app_config.state.current_song:
|
||||
return
|
||||
offset_seconds = timedelta(seconds=offset / second_microsecond_conversion)
|
||||
new_seconds = self.app_config.state.song_progress + offset_seconds
|
||||
new_seconds = self.app_config.state.song_progress + timedelta(
|
||||
microseconds=offset
|
||||
)
|
||||
|
||||
# This should not ever happen. The current_song should always have
|
||||
# a duration, but the Child object has `duration` optional because
|
||||
@@ -337,7 +370,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.on_song_scrub(
|
||||
None,
|
||||
(
|
||||
new_seconds
|
||||
new_seconds.total_seconds()
|
||||
/ self.app_config.state.current_song.duration.total_seconds()
|
||||
)
|
||||
* 100,
|
||||
@@ -346,7 +379,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def set_pos_fn(track_id: str, position: float = 0):
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
pos_seconds = timedelta(seconds=position / second_microsecond_conversion)
|
||||
pos_seconds = timedelta(microseconds=position)
|
||||
self.app_config.state.song_progress = pos_seconds
|
||||
track_id, occurrence = track_id.split("/")[-2:]
|
||||
|
||||
@@ -539,6 +572,15 @@ class SublimeMusicApp(Gtk.Application):
|
||||
AdapterManager.on_offline_mode_change(offline_mode)
|
||||
|
||||
del state_updates["__settings__"]
|
||||
self.app_config.save()
|
||||
|
||||
if player_setting := state_updates.get("__player_setting__"):
|
||||
player_name, option_name, value = player_setting
|
||||
self.app_config.player_config[player_name][option_name] = value
|
||||
del state_updates["__player_setting__"]
|
||||
if pm := self.player_manager:
|
||||
pm.change_settings(self.app_config.player_config)
|
||||
self.app_config.save()
|
||||
|
||||
for k, v in state_updates.items():
|
||||
setattr(self.app_config.state, k, v)
|
||||
@@ -582,7 +624,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if confirm_dialog.run() == Gtk.ResponseType.YES:
|
||||
assert self.app_config.cache_location
|
||||
provider_dir = self.app_config.cache_location.joinpath(provider.id)
|
||||
shutil.rmtree(str(provider_dir))
|
||||
shutil.rmtree(str(provider_dir), ignore_errors=True)
|
||||
del self.app_config.providers[provider.id]
|
||||
|
||||
confirm_dialog.destroy()
|
||||
@@ -601,8 +643,8 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
self.app_config.state.playing = not self.app_config.state.playing
|
||||
|
||||
if self.player.song_loaded:
|
||||
self.player.toggle_play()
|
||||
if self.player_manager.song_loaded:
|
||||
self.player_manager.toggle_play()
|
||||
self.save_play_queue()
|
||||
else:
|
||||
# This is from a restart, start playing the file.
|
||||
@@ -736,11 +778,14 @@ class SublimeMusicApp(Gtk.Application):
|
||||
def on_go_online(self, *args):
|
||||
self.on_refresh_window(None, {"__settings__": {"offline_mode": False}})
|
||||
|
||||
def on_refresh_devices(self, *args):
|
||||
self.player_manager.refresh_players()
|
||||
|
||||
def reset_state(self):
|
||||
if self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
self.loading_state = True
|
||||
self.player.reset()
|
||||
self.player_manager.reset()
|
||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||
self.loading_state = False
|
||||
|
||||
@@ -817,7 +862,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.save_play_queue()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_song_scrub(self, win: Any, scrub_value: float):
|
||||
def on_song_scrub(self, _, scrub_value: float):
|
||||
if not self.app_config.state.current_song or not self.window:
|
||||
return
|
||||
|
||||
@@ -835,33 +880,27 @@ class SublimeMusicApp(Gtk.Application):
|
||||
)
|
||||
|
||||
# If already playing, then make the player itself seek.
|
||||
if self.player and self.player.song_loaded:
|
||||
self.player.seek(new_time)
|
||||
if self.player_manager and self.player_manager.song_loaded:
|
||||
self.player_manager.seek(new_time)
|
||||
|
||||
self.save_play_queue()
|
||||
|
||||
def on_device_update(self, win: Any, device_uuid: str):
|
||||
assert self.player
|
||||
if device_uuid == self.app_config.state.current_device:
|
||||
def on_device_update(self, _, device_id: str):
|
||||
assert self.player_manager
|
||||
if device_id == self.app_config.state.current_device:
|
||||
return
|
||||
self.app_config.state.current_device = device_uuid
|
||||
self.app_config.state.current_device = device_id
|
||||
|
||||
was_playing = self.app_config.state.playing
|
||||
self.player.pause()
|
||||
self.player._song_loaded = False
|
||||
self.app_config.state.playing = False
|
||||
if was_playing := self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
|
||||
self.player_manager.set_current_device_id(self.app_config.state.current_device)
|
||||
|
||||
if self.dbus_manager:
|
||||
self.dbus_manager.property_diff()
|
||||
|
||||
self.update_window()
|
||||
|
||||
if device_uuid == "this device":
|
||||
self.player = self.mpv_player
|
||||
else:
|
||||
self.chromecast_player.set_playing_chromecast(device_uuid)
|
||||
self.player = self.chromecast_player
|
||||
|
||||
if was_playing:
|
||||
self.on_play_pause()
|
||||
if self.dbus_manager:
|
||||
@@ -870,14 +909,14 @@ class SublimeMusicApp(Gtk.Application):
|
||||
@dbus_propagate()
|
||||
def on_mute_toggle(self, *args):
|
||||
self.app_config.state.is_muted = not self.app_config.state.is_muted
|
||||
self.player.is_muted = self.app_config.state.is_muted
|
||||
self.player_manager.set_muted(self.app_config.state.is_muted)
|
||||
self.update_window()
|
||||
|
||||
@dbus_propagate()
|
||||
def on_volume_change(self, _, value: float):
|
||||
assert self.player
|
||||
assert self.player_manager
|
||||
self.app_config.state.volume = value
|
||||
self.player.volume = self.app_config.state.volume
|
||||
self.player_manager.set_volume(self.app_config.state.volume)
|
||||
self.update_window()
|
||||
|
||||
def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool:
|
||||
@@ -923,13 +962,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if self.app_config.provider is None:
|
||||
return
|
||||
|
||||
if self.player:
|
||||
self.player.pause()
|
||||
self.chromecast_player.shutdown()
|
||||
self.mpv_player.shutdown()
|
||||
if self.player_manager:
|
||||
if self.app_config.state.playing:
|
||||
self.save_play_queue()
|
||||
self.player_manager.pause()
|
||||
self.player_manager.shutdown()
|
||||
|
||||
self.app_config.save()
|
||||
self.save_play_queue()
|
||||
if self.dbus_manager:
|
||||
self.dbus_manager.shutdown()
|
||||
AdapterManager.shutdown()
|
||||
@@ -963,24 +1002,46 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if not self.window:
|
||||
return
|
||||
logging.info(f"Updating window force={force}")
|
||||
GLib.idle_add(lambda: self.window.update(self.app_config, force=force))
|
||||
GLib.idle_add(
|
||||
lambda: self.window.update(
|
||||
self.app_config, self.player_manager, force=force
|
||||
)
|
||||
)
|
||||
|
||||
def update_play_state_from_server(self, prompt_confirm: bool = False):
|
||||
# TODO (#129): need to make the play queue list loading for the duration here if
|
||||
# prompt_confirm is False.
|
||||
if not prompt_confirm and self.app_config.state.playing:
|
||||
assert self.player
|
||||
self.player.pause()
|
||||
assert self.player_manager
|
||||
self.player_manager.pause()
|
||||
self.app_config.state.playing = False
|
||||
self.update_window()
|
||||
|
||||
def do_update(f: Result[PlayQueue]):
|
||||
play_queue = f.result()
|
||||
if not play_queue:
|
||||
return
|
||||
play_queue.position = play_queue.position or timedelta(0)
|
||||
|
||||
new_play_queue = tuple(s.id for s in play_queue.songs)
|
||||
new_song_progress = play_queue.position
|
||||
|
||||
def do_resume(clear_notification: bool):
|
||||
assert self.player_manager
|
||||
if was_playing := self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
|
||||
self.app_config.state.play_queue = new_play_queue
|
||||
self.app_config.state.song_progress = play_queue.position
|
||||
self.app_config.state.current_song_index = play_queue.current_index or 0
|
||||
self.player_manager.reset()
|
||||
if clear_notification:
|
||||
self.app_config.state.current_notification = None
|
||||
self.update_window()
|
||||
|
||||
if was_playing:
|
||||
self.on_play_pause()
|
||||
|
||||
if prompt_confirm:
|
||||
# If there's not a significant enough difference in the song state,
|
||||
# don't prompt.
|
||||
@@ -1000,7 +1061,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if song_index == play_queue.current_index and progress_diff < 15:
|
||||
return
|
||||
|
||||
# TODO (#167): info bar here (maybe pop up above the player controls?)
|
||||
# Show a notification to resume the play queue.
|
||||
resume_text = "Do you want to resume the play queue"
|
||||
if play_queue.changed_by or play_queue.changed:
|
||||
resume_text += " saved"
|
||||
@@ -1013,28 +1074,15 @@ class SublimeMusicApp(Gtk.Application):
|
||||
resume_text += f" at {changed_str}"
|
||||
resume_text += "?"
|
||||
|
||||
def on_resume_click():
|
||||
if was_playing := self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
|
||||
self.app_config.state.play_queue = new_play_queue
|
||||
self.app_config.state.song_progress = play_queue.position
|
||||
self.app_config.state.current_song_index = (
|
||||
play_queue.current_index or 0
|
||||
)
|
||||
self.player.reset()
|
||||
self.app_config.state.current_notification = None
|
||||
self.update_window()
|
||||
|
||||
if was_playing:
|
||||
self.on_play_pause()
|
||||
|
||||
self.app_config.state.current_notification = UIState.UINotification(
|
||||
markup=f"<b>{resume_text}</b>",
|
||||
actions=(("Resume", on_resume_click),),
|
||||
actions=(("Resume", partial(do_resume, True)),),
|
||||
)
|
||||
self.update_window()
|
||||
|
||||
else: # just resume the play queue immediately
|
||||
do_resume(False)
|
||||
|
||||
play_queue_future = AdapterManager.get_play_queue()
|
||||
play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f))
|
||||
|
||||
@@ -1050,7 +1098,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
playable_song_search_direction: int = 1,
|
||||
):
|
||||
def do_reset():
|
||||
self.player.reset()
|
||||
self.player_manager.reset()
|
||||
self.app_config.state.song_progress = timedelta(0)
|
||||
self.should_scrobble_song = True
|
||||
|
||||
@@ -1058,10 +1106,12 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# in the callback.
|
||||
@dbus_propagate(self)
|
||||
def do_play_song(order_token: int, song: Song):
|
||||
assert self.player
|
||||
assert self.player_manager
|
||||
if order_token != self.song_playing_order_token:
|
||||
return
|
||||
|
||||
# TODO (#189): make this actually use the player's allowed list of schemes
|
||||
# to play.
|
||||
uri = AdapterManager.get_song_filename_or_stream(song)
|
||||
|
||||
# Prevent it from doing the thing where it continually loads
|
||||
@@ -1073,7 +1123,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if order_token != self.song_playing_order_token:
|
||||
return
|
||||
|
||||
self.player.play_media(
|
||||
self.player_manager.play_media(
|
||||
uri,
|
||||
timedelta(0) if reset else self.app_config.state.song_progress,
|
||||
song,
|
||||
@@ -1160,9 +1210,9 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# Switch to the local media if the player can hotswap without lag.
|
||||
# For example, MPV can is barely noticable whereas there's quite a
|
||||
# delay with Chromecast.
|
||||
assert self.player
|
||||
if self.player.can_hotswap_source:
|
||||
self.player.play_media(
|
||||
assert self.player_manager
|
||||
if self.player_manager.can_start_playing_with_no_latency:
|
||||
self.player_manager.play_media(
|
||||
AdapterManager.get_song_filename_or_stream(song),
|
||||
self.app_config.state.song_progress,
|
||||
song,
|
||||
@@ -1277,9 +1327,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if not can_play:
|
||||
# There are no songs that can be played. Show a notification that you
|
||||
# have to go online to play anything and then don't go further.
|
||||
was_playing = False
|
||||
if self.app_config.state.playing:
|
||||
was_playing = True
|
||||
if was_playing := self.app_config.state.playing:
|
||||
self.on_play_pause()
|
||||
|
||||
def go_online_clicked():
|
||||
|
@@ -2,9 +2,8 @@ import logging
|
||||
import os
|
||||
import pickle
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, cast, Dict, Optional, Type
|
||||
from typing import Any, cast, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import dataclasses_json
|
||||
from dataclasses_json import config, DataClassJsonMixin
|
||||
@@ -19,30 +18,17 @@ def encode_path(path: Path) -> str:
|
||||
|
||||
|
||||
dataclasses_json.cfg.global_config.decoders[Path] = Path
|
||||
dataclasses_json.cfg.global_config.decoders[Optional[Path]] = (
|
||||
dataclasses_json.cfg.global_config.decoders[
|
||||
Optional[Path] # type: ignore
|
||||
] = (
|
||||
lambda p: Path(p) if p else None
|
||||
)
|
||||
|
||||
|
||||
dataclasses_json.cfg.global_config.encoders[Path] = encode_path
|
||||
dataclasses_json.cfg.global_config.encoders[Optional[Path]] = encode_path
|
||||
|
||||
|
||||
class ReplayGainType(Enum):
|
||||
NO = 0
|
||||
TRACK = 1
|
||||
ALBUM = 2
|
||||
|
||||
def as_string(self) -> str:
|
||||
return ["no", "track", "album"][self.value]
|
||||
|
||||
@staticmethod
|
||||
def from_string(replay_gain_type: str) -> "ReplayGainType":
|
||||
return {
|
||||
"no": ReplayGainType.NO,
|
||||
"disabled": ReplayGainType.NO,
|
||||
"track": ReplayGainType.TRACK,
|
||||
"album": ReplayGainType.ALBUM,
|
||||
}[replay_gain_type.lower()]
|
||||
dataclasses_json.cfg.global_config.encoders[
|
||||
Optional[Path] # type: ignore
|
||||
] = encode_path
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -127,19 +113,23 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
current_provider_id: Optional[str] = None
|
||||
_loaded_provider_id: Optional[str] = field(default=None, init=False)
|
||||
|
||||
# Players
|
||||
player_config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
# Global Settings
|
||||
song_play_notification: bool = True
|
||||
offline_mode: bool = False
|
||||
serve_over_lan: bool = True
|
||||
port_number: int = 8282
|
||||
replay_gain: ReplayGainType = ReplayGainType.NO
|
||||
allow_song_downloads: bool = True
|
||||
download_on_stream: bool = True # also download when streaming a song
|
||||
prefetch_amount: int = 3
|
||||
concurrent_download_limit: int = 5
|
||||
|
||||
# Deprecated
|
||||
always_stream: bool = False # always stream instead of downloading songs
|
||||
# Deprecated. These have also been renamed to avoid using them elsewhere in the app.
|
||||
_sol: bool = field(default=True, metadata=config(field_name="serve_over_lan"))
|
||||
_pn: int = field(default=8282, metadata=config(field_name="port_number"))
|
||||
_rg: int = field(default=0, metadata=config(field_name="replay_gain"))
|
||||
|
||||
@staticmethod
|
||||
def load_from_file(filename: Path) -> "AppConfiguration":
|
||||
@@ -169,7 +159,16 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
for _, provider in self.providers.items():
|
||||
provider.migrate()
|
||||
|
||||
self.version = 5
|
||||
if self.version < 6:
|
||||
self.player_config = {
|
||||
"Local Playback": {"Replay Gain": ["no", "track", "album"][self._rg]},
|
||||
"Chromecast": {
|
||||
"Serve Local Files to Chromecasts on the LAN": self._sol,
|
||||
"LAN Server Port Number": self._pn,
|
||||
},
|
||||
}
|
||||
|
||||
self.version = 6
|
||||
self.state.migrate()
|
||||
|
||||
@property
|
||||
@@ -207,10 +206,8 @@ class AppConfiguration(DataClassJsonMixin):
|
||||
if not (provider := self.provider):
|
||||
return None
|
||||
|
||||
state_filename = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
|
||||
return state_filename.expanduser().joinpath(
|
||||
"sublime-music", provider.id, "state.pickle"
|
||||
)
|
||||
assert self.cache_location
|
||||
return self.cache_location.joinpath(provider.id, "state.pickle")
|
||||
|
||||
def save(self):
|
||||
# Save the config as YAML.
|
||||
|
@@ -11,7 +11,7 @@ from gi.repository import Gio, GLib
|
||||
|
||||
from sublime.adapters import AdapterManager, CacheMissError
|
||||
from sublime.config import AppConfiguration
|
||||
from sublime.players import Player
|
||||
from sublime.players import PlayerManager
|
||||
from sublime.ui.state import RepeatType
|
||||
|
||||
|
||||
@@ -53,9 +53,11 @@ class DBusManager:
|
||||
on_set_property: Callable[
|
||||
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None
|
||||
],
|
||||
get_config_and_player: Callable[[], Tuple[AppConfiguration, Optional[Player]]],
|
||||
get_config_and_player_manager: Callable[
|
||||
[], Tuple[AppConfiguration, Optional[PlayerManager]]
|
||||
],
|
||||
):
|
||||
self.get_config_and_player = get_config_and_player
|
||||
self.get_config_and_player_manager = get_config_and_player_manager
|
||||
self.do_on_method_call = do_on_method_call
|
||||
self.on_set_property = on_set_property
|
||||
self.connection = connection
|
||||
@@ -187,8 +189,8 @@ class DBusManager:
|
||||
return DBusManager._escape_re.sub(replace, id)
|
||||
|
||||
def property_dict(self) -> Dict[str, Any]:
|
||||
config, player = self.get_config_and_player()
|
||||
if config is None or player is None:
|
||||
config, player_manager = self.get_config_and_player_manager()
|
||||
if config is None or player_manager is None:
|
||||
return {}
|
||||
|
||||
state = config.state
|
||||
@@ -225,7 +227,7 @@ class DBusManager:
|
||||
(False, True): "Stopped",
|
||||
(True, False): "Paused",
|
||||
(True, True): "Playing",
|
||||
}[player is not None and player.song_loaded, state.playing],
|
||||
}[player_manager.song_loaded, state.playing],
|
||||
"LoopStatus": state.repeat_type.as_mpris_loop_status(),
|
||||
"Rate": 1.0,
|
||||
"Shuffle": state.shuffle_on,
|
||||
|
@@ -1,532 +0,0 @@
|
||||
import abc
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from time import sleep
|
||||
from typing import Any, Callable, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
import bottle
|
||||
import mpv
|
||||
import pychromecast
|
||||
|
||||
from sublime.adapters import AdapterManager
|
||||
from sublime.adapters.api_objects import Song
|
||||
from sublime.config import AppConfiguration
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerEvent:
|
||||
class Type(Enum):
|
||||
PLAY_STATE_CHANGE = 0
|
||||
VOLUME_CHANGE = 1
|
||||
STREAM_CACHE_PROGRESS_CHANGE = 2
|
||||
CONNECTING = 3
|
||||
CONNECTED = 4
|
||||
|
||||
type: Type
|
||||
playing: Optional[bool] = False
|
||||
volume: Optional[float] = 0.0
|
||||
stream_cache_duration: Optional[float] = 0.0
|
||||
|
||||
|
||||
class Player(abc.ABC):
|
||||
# TODO (#205): pull players out into different modules and actually document this
|
||||
# API because it's kinda a bit strange tbh.
|
||||
_can_hotswap_source: bool
|
||||
|
||||
# TODO (#205): unify on_timepos_change and on_player_event?
|
||||
def __init__(
|
||||
self,
|
||||
on_timepos_change: Callable[[Optional[float]], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
config: AppConfiguration,
|
||||
):
|
||||
self.on_timepos_change = on_timepos_change
|
||||
self.on_track_end = on_track_end
|
||||
self.on_player_event = on_player_event
|
||||
self.config = config
|
||||
self._song_loaded = False
|
||||
|
||||
@property
|
||||
def playing(self) -> bool:
|
||||
return self._is_playing()
|
||||
|
||||
@property
|
||||
def song_loaded(self) -> bool:
|
||||
return self._song_loaded
|
||||
|
||||
@property
|
||||
def can_hotswap_source(self) -> bool:
|
||||
return self._can_hotswap_source
|
||||
|
||||
@property
|
||||
def volume(self) -> float:
|
||||
return self._get_volume()
|
||||
|
||||
@volume.setter
|
||||
def volume(self, value: float):
|
||||
self._set_volume(value)
|
||||
|
||||
@property
|
||||
def is_muted(self) -> bool:
|
||||
return self._get_is_muted()
|
||||
|
||||
@is_muted.setter
|
||||
def is_muted(self, value: bool):
|
||||
self._set_is_muted(value)
|
||||
|
||||
def reset(self):
|
||||
raise NotImplementedError("reset must be implemented by implementor of Player")
|
||||
|
||||
def play_media(self, file_or_url: str, progress: timedelta, song: Song):
|
||||
raise NotImplementedError(
|
||||
"play_media must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def _is_playing(self):
|
||||
raise NotImplementedError(
|
||||
"_is_playing must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
def pause(self):
|
||||
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"
|
||||
)
|
||||
|
||||
def seek(self, value: timedelta):
|
||||
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"
|
||||
)
|
||||
|
||||
def _get_volume(self):
|
||||
raise NotImplementedError(
|
||||
"_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"
|
||||
)
|
||||
|
||||
def _get_is_muted(self):
|
||||
raise NotImplementedError(
|
||||
"_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"
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
raise NotImplementedError(
|
||||
"shutdown must be implemented by implementor of Player"
|
||||
)
|
||||
|
||||
|
||||
class MPVPlayer(Player):
|
||||
def __init__(
|
||||
self,
|
||||
on_timepos_change: Callable[[Optional[float]], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
config: AppConfiguration,
|
||||
):
|
||||
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.replaygain = config.replay_gain.as_string()
|
||||
self.progress_value_lock = threading.Lock()
|
||||
self.progress_value_count = 0
|
||||
self._muted = False
|
||||
self._volume = 100.0
|
||||
self._can_hotswap_source = True
|
||||
|
||||
@self.mpv.property_observer("time-pos")
|
||||
def time_observer(_, value: Optional[float]):
|
||||
self.on_timepos_change(value)
|
||||
if value is None and self.progress_value_count > 1:
|
||||
self.on_track_end()
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count = 0
|
||||
|
||||
if value:
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count += 1
|
||||
|
||||
@self.mpv.property_observer("demuxer-cache-time")
|
||||
def cache_size_observer(_, value: Optional[float]):
|
||||
on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.Type.STREAM_CACHE_PROGRESS_CHANGE,
|
||||
stream_cache_duration=value,
|
||||
)
|
||||
)
|
||||
|
||||
def _is_playing(self) -> bool:
|
||||
return not self.mpv.pause
|
||||
|
||||
def reset(self):
|
||||
self._song_loaded = False
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count = 0
|
||||
|
||||
def play_media(self, file_or_url: str, progress: timedelta, song: Song):
|
||||
self.had_progress_value = False
|
||||
with self.progress_value_lock:
|
||||
self.progress_value_count = 0
|
||||
|
||||
self.mpv.pause = False
|
||||
self.mpv.command(
|
||||
"loadfile",
|
||||
file_or_url,
|
||||
"replace",
|
||||
f"force-seekable=yes,start={progress.total_seconds()}" if progress else "",
|
||||
)
|
||||
self._song_loaded = True
|
||||
|
||||
def pause(self):
|
||||
self.mpv.pause = True
|
||||
|
||||
def toggle_play(self):
|
||||
self.mpv.cycle("pause")
|
||||
|
||||
def seek(self, value: timedelta):
|
||||
self.mpv.seek(str(value.total_seconds()), "absolute")
|
||||
|
||||
def _get_volume(self) -> float:
|
||||
return self._volume
|
||||
|
||||
def _set_volume(self, value: float):
|
||||
self._volume = value
|
||||
self.mpv.volume = self._volume
|
||||
|
||||
def _get_is_muted(self) -> bool:
|
||||
return self._muted
|
||||
|
||||
def _set_is_muted(self, value: bool):
|
||||
self._muted = value
|
||||
self.mpv.volume = 0 if value else self._volume
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
class ChromecastPlayer(Player):
|
||||
chromecasts: List[Any] = []
|
||||
chromecast: pychromecast.Chromecast = None
|
||||
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=10)
|
||||
|
||||
class CastStatusListener:
|
||||
on_new_cast_status: Optional[Callable] = None
|
||||
|
||||
def new_cast_status(self, status: Any):
|
||||
if self.on_new_cast_status:
|
||||
self.on_new_cast_status(status)
|
||||
|
||||
class MediaStatusListener:
|
||||
on_new_media_status: Optional[Callable] = None
|
||||
|
||||
def new_media_status(self, status: Any):
|
||||
if self.on_new_media_status:
|
||||
self.on_new_media_status(status)
|
||||
|
||||
cast_status_listener = CastStatusListener()
|
||||
media_status_listener = MediaStatusListener()
|
||||
|
||||
class ServerThread(threading.Thread):
|
||||
def __init__(self, host: str, port: int):
|
||||
super().__init__()
|
||||
self.daemon = True
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.token: Optional[str] = None
|
||||
self.song_id: Optional[str] = None
|
||||
|
||||
self.app = bottle.Bottle()
|
||||
|
||||
@self.app.route("/")
|
||||
def index() -> str:
|
||||
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>")
|
||||
def stream_song(token: str) -> bytes:
|
||||
assert self.song_id
|
||||
|
||||
if token != self.token:
|
||||
raise bottle.HTTPError(status=401, body="Invalid token.")
|
||||
|
||||
# TODO (#189) refactor this so that the players can specify what types
|
||||
# of URIs they can play. Set it to just ("http", "https") when streaming
|
||||
# from the local filesystem is disabled and set it to ("file", "http",
|
||||
# "https") in the other case.
|
||||
song = AdapterManager.get_song_details(self.song_id).result()
|
||||
filename = AdapterManager.get_song_filename_or_stream(song)
|
||||
with open(filename, "rb") as fin:
|
||||
song_buffer = io.BytesIO(fin.read())
|
||||
|
||||
bottle.response.set_header(
|
||||
"Content-Type", mimetypes.guess_type(filename)[0],
|
||||
)
|
||||
bottle.response.set_header("Accept-Ranges", "bytes")
|
||||
return song_buffer.read()
|
||||
|
||||
def set_song_and_token(self, song_id: str, token: str):
|
||||
self.song_id = song_id
|
||||
self.token = token
|
||||
|
||||
def run(self):
|
||||
bottle.run(self.app, host=self.host, port=self.port)
|
||||
|
||||
getting_chromecasts = False
|
||||
|
||||
@classmethod
|
||||
def get_chromecasts(cls) -> Future:
|
||||
def do_get_chromecasts() -> List[pychromecast.Chromecast]:
|
||||
if not ChromecastPlayer.getting_chromecasts:
|
||||
logging.info("Getting Chromecasts")
|
||||
ChromecastPlayer.getting_chromecasts = True
|
||||
ChromecastPlayer.chromecasts = pychromecast.get_chromecasts()
|
||||
else:
|
||||
logging.info("Already getting Chromecasts... busy wait")
|
||||
while ChromecastPlayer.getting_chromecasts:
|
||||
sleep(0.1)
|
||||
|
||||
ChromecastPlayer.getting_chromecasts = False
|
||||
return ChromecastPlayer.chromecasts
|
||||
|
||||
return ChromecastPlayer.executor.submit(do_get_chromecasts)
|
||||
|
||||
def set_playing_chromecast(self, uuid: str):
|
||||
self.on_player_event(PlayerEvent(PlayerEvent.Type.CONNECTING))
|
||||
self.chromecast = next(
|
||||
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)
|
||||
self.chromecast.wait()
|
||||
logging.info(f"Connected to Chromecast: {self.chromecast.device.friendly_name}")
|
||||
self.on_player_event(PlayerEvent(PlayerEvent.Type.CONNECTED))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_timepos_change: Callable[[Optional[float]], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
config: AppConfiguration,
|
||||
):
|
||||
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
|
||||
)
|
||||
ChromecastPlayer.media_status_listener.on_new_media_status = (
|
||||
self.on_new_media_status
|
||||
)
|
||||
|
||||
# Set host_ip
|
||||
# TODO (#128): should have a mechanism to update this. Maybe it should
|
||||
# be determined every time we try and play a song.
|
||||
# TODO (#129): does not work properly when on VPNs when the DNS is
|
||||
# piped over the VPN tunnel.
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
self.host_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except OSError:
|
||||
self.host_ip = None
|
||||
|
||||
self.port = config.port_number
|
||||
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.start()
|
||||
|
||||
def on_new_cast_status(
|
||||
self, status: pychromecast.socket_client.CastStatus,
|
||||
):
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.Type.VOLUME_CHANGE,
|
||||
volume=(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(PlayerEvent.Type.PLAY_STATE_CHANGE, playing=False)
|
||||
)
|
||||
self._song_loaded = False
|
||||
|
||||
def on_new_media_status(
|
||||
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
|
||||
):
|
||||
self.on_track_end()
|
||||
|
||||
self._timepos = status.current_time
|
||||
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.Type.PLAY_STATE_CHANGE,
|
||||
playing=(status.player_state in ("PLAYING", "BUFFERING")),
|
||||
)
|
||||
)
|
||||
|
||||
# Start the time incrementor just in case this was a play notification.
|
||||
self.start_time_incrementor()
|
||||
|
||||
def time_incrementor(self):
|
||||
if self.time_incrementor_running:
|
||||
return
|
||||
|
||||
self.time_incrementor_running = True
|
||||
while True:
|
||||
if not self.playing:
|
||||
self.time_incrementor_running = False
|
||||
return
|
||||
|
||||
self._timepos += 0.5
|
||||
self.on_timepos_change(self._timepos)
|
||||
sleep(0.5)
|
||||
|
||||
def start_time_incrementor(self):
|
||||
ChromecastPlayer.executor.submit(self.time_incrementor)
|
||||
|
||||
def wait_for_playing(self, callback: Callable, url: str = None):
|
||||
def do_wait_for_playing():
|
||||
while True:
|
||||
sleep(0.1)
|
||||
if self.playing:
|
||||
break
|
||||
if url is not None:
|
||||
if url == self.chromecast.media_controller.status.content_id:
|
||||
break
|
||||
|
||||
callback()
|
||||
|
||||
ChromecastPlayer.executor.submit(do_wait_for_playing)
|
||||
|
||||
def _is_playing(self) -> bool:
|
||||
if not self.chromecast or not self.chromecast.media_controller:
|
||||
return False
|
||||
return self.chromecast.media_controller.status.player_is_playing
|
||||
|
||||
def reset(self):
|
||||
self._song_loaded = False
|
||||
|
||||
def play_media(self, file_or_url: str, progress: timedelta, song: Song):
|
||||
stream_scheme = urlparse(file_or_url).scheme
|
||||
# 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(8)).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}"
|
||||
else:
|
||||
file_or_url = AdapterManager.get_song_filename_or_stream(
|
||||
song, force_stream=True,
|
||||
)
|
||||
|
||||
cover_art_url = AdapterManager.get_cover_art_uri(song.cover_art, size=1000)
|
||||
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",
|
||||
current_time=progress.total_seconds(),
|
||||
title=song.title,
|
||||
thumb=cover_art_url,
|
||||
metadata={
|
||||
"metadataType": 3,
|
||||
"albumName": song.album.name if song.album else None,
|
||||
"artist": song.artist.name if song.artist else None,
|
||||
"trackNumber": song.track,
|
||||
},
|
||||
)
|
||||
self._timepos = progress.total_seconds()
|
||||
|
||||
def on_play_begin():
|
||||
# TODO (#206) this starts too soon, do something better
|
||||
self._song_loaded = True
|
||||
self.start_time_incrementor()
|
||||
|
||||
self.wait_for_playing(on_play_begin, url=file_or_url)
|
||||
|
||||
def pause(self):
|
||||
if self.chromecast and self.chromecast.media_controller:
|
||||
self.chromecast.media_controller.pause()
|
||||
|
||||
def toggle_play(self):
|
||||
if self.playing:
|
||||
self.chromecast.media_controller.pause()
|
||||
else:
|
||||
self.chromecast.media_controller.play()
|
||||
self.wait_for_playing(self.start_time_incrementor)
|
||||
|
||||
def seek(self, value: timedelta):
|
||||
do_pause = not self.playing
|
||||
self.chromecast.media_controller.seek(value.total_seconds())
|
||||
if do_pause:
|
||||
self.pause()
|
||||
|
||||
def _get_volume(self) -> float:
|
||||
if self.chromecast:
|
||||
return self.chromecast.status.volume_level * 100
|
||||
else:
|
||||
return 100
|
||||
|
||||
def _set_volume(self, value: float):
|
||||
# Chromecast volume is in the range [0, 1], not [0, 100].
|
||||
if self.chromecast:
|
||||
self.chromecast.set_volume(value / 100)
|
||||
|
||||
def _get_is_muted(self) -> bool:
|
||||
return self.chromecast.volume_muted
|
||||
|
||||
def _set_is_muted(self, value: bool):
|
||||
self.chromecast.set_volume_muted(value)
|
||||
|
||||
def shutdown(self):
|
||||
if self.chromecast:
|
||||
self.chromecast.disconnect(blocking=True)
|
7
sublime/players/__init__.py
Normal file
7
sublime/players/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .manager import PlayerDeviceEvent, PlayerEvent, PlayerManager
|
||||
|
||||
__all__ = (
|
||||
"PlayerDeviceEvent",
|
||||
"PlayerEvent",
|
||||
"PlayerManager",
|
||||
)
|
215
sublime/players/base.py
Normal file
215
sublime/players/base.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, Optional, Set, Tuple, Type, Union
|
||||
|
||||
from sublime.adapters.api_objects import Song
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerEvent:
|
||||
"""
|
||||
Represents an event triggered by the player. This is a way to signal state changes
|
||||
to Sublime Music if the player can be controlled outside of Sublime Music (for
|
||||
example, Chromecast player).
|
||||
|
||||
Each player event has a :class:`PlayerEvent.EventType`. Additionally, each event
|
||||
type has additional information in the form of additional properties on the
|
||||
:class:`PlayerEvent` object.
|
||||
|
||||
* :class:`PlayerEvent.EventType.PLAY_STATE_CHANGE` -- indicates that the play state
|
||||
of the player has changed. The :class:`PlayerEvent.playing` property is required
|
||||
for this event type.
|
||||
* :class:`PlayerEvent.EventType.VOLUME_CHANGE` -- indicates that the player's volume
|
||||
has changed. The :classs`PlayerEvent.volume` property is required for this event
|
||||
type and should be in the range [0, 100].
|
||||
* :class:`PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE` -- indicates that the
|
||||
stream cache progress has changed. When streaming a song, this will be used to
|
||||
show how much of the song has been loaded into the player. The
|
||||
:class:`PlayerEvent.stream_cache_duration` property is required for this event
|
||||
type and should be a float represent the number of seconds of the song that have
|
||||
been cached.
|
||||
* :class:`PlayerEvent.EventType.CONNECTING` -- indicates that a device is being
|
||||
connected to. The :class:`PlayerEvent.device_id` property is required for this
|
||||
event type and indicates the device ID that is being connected to.
|
||||
* :class:`PlayerEvent.EventType.CONNECTED` -- indicates that a device has been
|
||||
connected to. The :class:`PlayerEvent.device_id` property is required for this
|
||||
event type and indicates the device ID that has been connected to.
|
||||
"""
|
||||
|
||||
class EventType(Enum):
|
||||
PLAY_STATE_CHANGE = 0
|
||||
VOLUME_CHANGE = 1
|
||||
STREAM_CACHE_PROGRESS_CHANGE = 2
|
||||
CONNECTING = 3
|
||||
CONNECTED = 4
|
||||
DISCONNECT = 5
|
||||
|
||||
type: EventType
|
||||
device_id: str
|
||||
playing: Optional[bool] = None
|
||||
volume: Optional[float] = None
|
||||
stream_cache_duration: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerDeviceEvent:
|
||||
class Delta(Enum):
|
||||
ADD = 0
|
||||
REMOVE = 1
|
||||
|
||||
delta: Delta
|
||||
player_type: Type
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class Player(abc.ABC):
|
||||
song_loaded = False
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def name(self) -> str:
|
||||
"""
|
||||
:returns: returns the friendly name of the player for display in the UI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def supported_schemes(self) -> Set[str]:
|
||||
"""
|
||||
:returns: a set of all the schemes that the player can play.
|
||||
"""
|
||||
|
||||
@property
|
||||
def can_start_playing_with_no_latency(self) -> bool:
|
||||
"""
|
||||
:returns: whether the player can start playing a song with no latency.
|
||||
"""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
|
||||
"""
|
||||
:returns: a dictionary of configuration key -> type of the option or tuple of
|
||||
options (for a dropdown menu).
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(
|
||||
self,
|
||||
on_timepos_change: Callable[[Optional[float]], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
player_device_change_callback: Callable[[PlayerDeviceEvent], None],
|
||||
config: Dict[str, Union[str, int, bool]],
|
||||
):
|
||||
"""
|
||||
Initialize the player.
|
||||
|
||||
:param config: A dictionary of configuration key -> configuration value.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def change_settings(self, config: Dict[str, Union[str, int, bool]]):
|
||||
"""
|
||||
This function is called when the player settings are changed (normally this
|
||||
happens when the user changes the settings in the UI).
|
||||
|
||||
:param config: A dictionary of configuration key -> configuration value.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def refresh_players(self):
|
||||
"""
|
||||
This function is called when the user requests the player list to be refreshed
|
||||
in the UI.
|
||||
|
||||
This function should call the ``player_device_change_callback`` with the delta
|
||||
events to indicate changes to the UI. If there is no reason to refresh (for
|
||||
example, the MPV player), then this function can do nothing.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_current_device_id(self, device_id: str):
|
||||
"""
|
||||
Switch to the given device ID.
|
||||
"""
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the player.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def shutdown(self):
|
||||
"""
|
||||
Do any cleanup of the player.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def playing(self) -> bool:
|
||||
"""
|
||||
:returns: whether or not the player is currently playing a song.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_volume(self) -> float:
|
||||
"""
|
||||
:returns: the current volume on a scale of [0, 100]
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_volume(self, volume: float):
|
||||
"""
|
||||
Set the volume of the player to the given value.
|
||||
|
||||
:param volume: the value to set the volume to. Will be in the range [0, 100]
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_is_muted(self) -> bool:
|
||||
"""
|
||||
:returns: whether or not the player is muted.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_muted(self, muted: bool):
|
||||
"""
|
||||
:param muted: set the player's "muted" property to the given value.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def play_media(self, uri: str, progress: timedelta, song: Song):
|
||||
"""
|
||||
:param uri: the URI to play. The URI is guaranteed to be one of the schemes in
|
||||
the :class:`supported_schemes` set for this adapter.
|
||||
:param progress: the time at which to start playing the song.
|
||||
:param song: the actual song. This could be used to set metadata and such on the
|
||||
player.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def pause(self):
|
||||
"""
|
||||
Pause the player.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def play(self):
|
||||
"""
|
||||
Play the current media.
|
||||
"""
|
||||
|
||||
def seek(self, position: timedelta):
|
||||
"""
|
||||
:param position: seek to the given position in the song.
|
||||
"""
|
347
sublime/players/chromecast.py
Normal file
347
sublime/players/chromecast.py
Normal file
@@ -0,0 +1,347 @@
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import multiprocessing
|
||||
import os
|
||||
import socket
|
||||
from datetime import timedelta
|
||||
from typing import Any, Callable, cast, Dict, Optional, Set, Tuple, Type, Union
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
from sublime.adapters import AdapterManager
|
||||
from sublime.adapters.api_objects import Song
|
||||
|
||||
from .base import Player, PlayerDeviceEvent, PlayerEvent
|
||||
|
||||
try:
|
||||
import pychromecast
|
||||
|
||||
chromecast_imported = True
|
||||
except Exception:
|
||||
chromecast_imported = False
|
||||
|
||||
try:
|
||||
import bottle
|
||||
|
||||
bottle_imported = True
|
||||
except Exception:
|
||||
bottle_imported = False
|
||||
|
||||
SERVE_FILES_KEY = "Serve Local Files to Chromecasts on the LAN"
|
||||
LAN_PORT_KEY = "LAN Server Port Number"
|
||||
|
||||
|
||||
class ChromecastPlayer(Player):
|
||||
name = "Chromecast"
|
||||
can_start_playing_with_no_latency = False
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return chromecast_imported
|
||||
|
||||
@staticmethod
|
||||
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
|
||||
if not bottle_imported:
|
||||
return {}
|
||||
return {SERVE_FILES_KEY: bool, LAN_PORT_KEY: int}
|
||||
|
||||
def supported_schemes(self) -> Set[str]:
|
||||
schemes = {"http", "https"}
|
||||
if bottle_imported and self.config.get(SERVE_FILES_KEY):
|
||||
schemes.add("file")
|
||||
return schemes
|
||||
|
||||
_timepos = 0.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_timepos_change: Callable[[Optional[float]], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
player_device_change_callback: Callable[[PlayerDeviceEvent], None],
|
||||
config: Dict[str, Union[str, int, bool]],
|
||||
):
|
||||
self.server_process: Optional[multiprocessing.Process] = None
|
||||
self.on_timepos_change = on_timepos_change
|
||||
self.on_track_end = on_track_end
|
||||
self.on_player_event = on_player_event
|
||||
self.player_device_change_callback = player_device_change_callback
|
||||
|
||||
self.change_settings(config)
|
||||
|
||||
if chromecast_imported:
|
||||
self._chromecasts: Dict[UUID, pychromecast.Chromecast] = {}
|
||||
self._current_chromecast: Optional[pychromecast.Chromecast] = None
|
||||
|
||||
self.stop_get_chromecasts = None
|
||||
self.refresh_players()
|
||||
|
||||
def chromecast_discovered_callback(self, chromecast: Any):
|
||||
chromecast = cast(pychromecast.Chromecast, chromecast)
|
||||
self._chromecasts[chromecast.device.uuid] = chromecast
|
||||
self.player_device_change_callback(
|
||||
PlayerDeviceEvent(
|
||||
PlayerDeviceEvent.Delta.ADD,
|
||||
type(self),
|
||||
str(chromecast.device.uuid),
|
||||
chromecast.device.friendly_name,
|
||||
)
|
||||
)
|
||||
|
||||
def change_settings(self, config: Dict[str, Union[str, int, bool]]):
|
||||
if not chromecast_imported:
|
||||
return
|
||||
|
||||
self.config = config
|
||||
if bottle_imported and self.config.get(SERVE_FILES_KEY):
|
||||
# Try and terminate the existing process if it exists.
|
||||
if self.server_process is not None:
|
||||
try:
|
||||
self.server_process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.server_process = multiprocessing.Process(
|
||||
target=self._run_server_process,
|
||||
args=("0.0.0.0", self.config.get(LAN_PORT_KEY)),
|
||||
)
|
||||
self.server_process.start()
|
||||
|
||||
def refresh_players(self):
|
||||
if not chromecast_imported:
|
||||
return
|
||||
|
||||
if self.stop_get_chromecasts is not None:
|
||||
self.stop_get_chromecasts()
|
||||
|
||||
for id_, chromecast in self._chromecasts.items():
|
||||
self.player_device_change_callback(
|
||||
PlayerDeviceEvent(
|
||||
PlayerDeviceEvent.Delta.REMOVE,
|
||||
type(self),
|
||||
str(id_),
|
||||
chromecast.device.friendly_name,
|
||||
)
|
||||
)
|
||||
|
||||
self._chromecasts = {}
|
||||
|
||||
self.stop_get_chromecasts = pychromecast.get_chromecasts(
|
||||
blocking=False, callback=self.chromecast_discovered_callback
|
||||
)
|
||||
|
||||
def set_current_device_id(self, device_id: str):
|
||||
self._current_chromecast = self._chromecasts[UUID(device_id)]
|
||||
self._current_chromecast.media_controller.register_status_listener(self)
|
||||
self._current_chromecast.register_status_listener(self)
|
||||
self._current_chromecast.wait()
|
||||
|
||||
def new_cast_status(self, status: Any):
|
||||
assert self._current_chromecast
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.EventType.VOLUME_CHANGE,
|
||||
str(self._current_chromecast.device.uuid),
|
||||
volume=(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(
|
||||
PlayerEvent.EventType.PLAY_STATE_CHANGE,
|
||||
str(self._current_chromecast.device.uuid),
|
||||
playing=False,
|
||||
)
|
||||
)
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.EventType.DISCONNECT,
|
||||
str(self._current_chromecast.device.uuid),
|
||||
)
|
||||
)
|
||||
self.song_loaded = False
|
||||
|
||||
time_increment_order_token = 0
|
||||
|
||||
def new_media_status(self, status: Any):
|
||||
# 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
|
||||
):
|
||||
self.on_track_end()
|
||||
return
|
||||
|
||||
self.song_loaded = True
|
||||
|
||||
self._timepos = status.current_time
|
||||
|
||||
assert self._current_chromecast
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.EventType.PLAY_STATE_CHANGE,
|
||||
str(self._current_chromecast.device.uuid),
|
||||
playing=(status.player_state in ("PLAYING", "BUFFERING")),
|
||||
)
|
||||
)
|
||||
|
||||
def increment_time(order_token: int):
|
||||
if self.time_increment_order_token != order_token or not self.playing:
|
||||
return
|
||||
|
||||
self._timepos += 0.5
|
||||
self.on_timepos_change(self._timepos)
|
||||
GLib.timeout_add(500, increment_time, order_token)
|
||||
|
||||
self.time_increment_order_token += 1
|
||||
GLib.timeout_add(500, increment_time, self.time_increment_order_token)
|
||||
|
||||
def shutdown(self):
|
||||
if self.server_process:
|
||||
self.server_process.terminate()
|
||||
|
||||
try:
|
||||
self._current_chromecast.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_serving_song_id = multiprocessing.Array("c", 1024) # huge buffer, just in case
|
||||
_serving_token = multiprocessing.Array("c", 16)
|
||||
|
||||
def _run_server_process(self, host: str, port: int):
|
||||
app = bottle.Bottle()
|
||||
|
||||
@app.route("/")
|
||||
def index() -> str:
|
||||
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>
|
||||
"""
|
||||
|
||||
@app.route("/s/<token>")
|
||||
def stream_song(token: str) -> bytes:
|
||||
if token != self._serving_token.value.decode():
|
||||
raise bottle.HTTPError(status=401, body="Invalid token.")
|
||||
|
||||
song = AdapterManager.get_song_details(
|
||||
self._serving_song_id.value.decode()
|
||||
).result()
|
||||
filename = AdapterManager.get_song_filename_or_stream(song)
|
||||
assert filename.startswith("file://")
|
||||
with open(filename[7:], "rb") as fin:
|
||||
song_buffer = io.BytesIO(fin.read())
|
||||
|
||||
content_type = mimetypes.guess_type(filename)[0]
|
||||
bottle.response.set_header("Content-Type", content_type)
|
||||
bottle.response.set_header("Accept-Ranges", "bytes")
|
||||
return song_buffer.read()
|
||||
|
||||
bottle.run(app, host=host, port=port)
|
||||
|
||||
@property
|
||||
def playing(self) -> bool:
|
||||
if (
|
||||
not self._current_chromecast
|
||||
or not self._current_chromecast.media_controller
|
||||
):
|
||||
return False
|
||||
return self._current_chromecast.media_controller.status.player_is_playing
|
||||
|
||||
def get_volume(self) -> float:
|
||||
if self._current_chromecast:
|
||||
# The volume is in the range [0, 1]. Multiply by 100 to get to [0, 100].
|
||||
return self._current_chromecast.status.volume_level * 100
|
||||
else:
|
||||
return 100
|
||||
|
||||
def set_volume(self, volume: float):
|
||||
if self._current_chromecast:
|
||||
# volume value is in [0, 100]. Convert to [0, 1] for Chromecast.
|
||||
self._current_chromecast.set_volume(volume / 100)
|
||||
|
||||
def get_is_muted(self) -> bool:
|
||||
if not self._current_chromecast:
|
||||
return False
|
||||
return self._current_chromecast.volume_muted
|
||||
|
||||
def set_muted(self, muted: bool):
|
||||
if not self._current_chromecast:
|
||||
return
|
||||
self._current_chromecast.set_volume_muted(muted)
|
||||
|
||||
def play_media(self, uri: str, progress: timedelta, song: Song):
|
||||
assert self._current_chromecast
|
||||
scheme = urlparse(uri).scheme
|
||||
if scheme == "file":
|
||||
token = base64.b16encode(os.urandom(8))
|
||||
self._serving_token.value = token
|
||||
self._serving_song_id.value = song.id.encode()
|
||||
|
||||
# If this fails, then we are basically screwed, so don't care if it blows
|
||||
# up.
|
||||
# TODO (#129): this does not work properly when on VPNs when the DNS is
|
||||
# piped over the VPN tunnel.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
host_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
uri = f"http://{host_ip}:{self.config.get(LAN_PORT_KEY)}/s/{token.decode()}"
|
||||
logging.info("Serving {song.name} at {uri}")
|
||||
|
||||
cover_art_url = AdapterManager.get_cover_art_uri(song.cover_art, size=1000)
|
||||
self._current_chromecast.media_controller.play_media(
|
||||
uri,
|
||||
# Just pretend that whatever we send it is mp3, even if it isn't.
|
||||
"audio/mp3",
|
||||
current_time=progress.total_seconds(),
|
||||
title=song.title,
|
||||
thumb=cover_art_url,
|
||||
metadata={
|
||||
"metadataType": 3,
|
||||
"albumName": song.album.name if song.album else None,
|
||||
"artist": song.artist.name if song.artist else None,
|
||||
"trackNumber": song.track,
|
||||
},
|
||||
)
|
||||
|
||||
# Make sure to clear out the cache duration state.
|
||||
self.on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE,
|
||||
str(self._current_chromecast.device.uuid),
|
||||
stream_cache_duration=0,
|
||||
)
|
||||
)
|
||||
self._timepos = progress.total_seconds()
|
||||
|
||||
def pause(self):
|
||||
if self._current_chromecast and self._current_chromecast.media_controller:
|
||||
self._current_chromecast.media_controller.pause()
|
||||
|
||||
def play(self):
|
||||
if self._current_chromecast and self._current_chromecast.media_controller:
|
||||
self._current_chromecast.media_controller.play()
|
||||
|
||||
def seek(self, position: timedelta):
|
||||
if not self._current_chromecast:
|
||||
return
|
||||
|
||||
do_pause = not self.playing
|
||||
self._current_chromecast.media_controller.seek(position.total_seconds())
|
||||
if do_pause:
|
||||
self.pause()
|
||||
|
||||
def _wait_for_playing(self):
|
||||
pass
|
166
sublime/players/manager.py
Normal file
166
sublime/players/manager.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
from sublime.adapters.api_objects import Song
|
||||
|
||||
from .base import PlayerDeviceEvent, PlayerEvent
|
||||
from .chromecast import ChromecastPlayer # noqa: F401
|
||||
from .mpv import MPVPlayer # noqa: F401
|
||||
|
||||
|
||||
class PlayerManager:
|
||||
# Available Players. Order matters for UI display.
|
||||
available_player_types: List[Type] = [MPVPlayer, ChromecastPlayer]
|
||||
|
||||
@staticmethod
|
||||
def get_configuration_options() -> Dict[
|
||||
str, Dict[str, Union[Type, Tuple[str, ...]]]
|
||||
]:
|
||||
"""
|
||||
:returns: Dictionary of the name of the player -> option configs (see
|
||||
:class:`sublime.players.base.Player.get_configuration_options` for details).
|
||||
"""
|
||||
return {
|
||||
p.name: p.get_configuration_options()
|
||||
for p in PlayerManager.available_player_types
|
||||
}
|
||||
|
||||
# Initialization and Shutdown
|
||||
def __init__(
|
||||
self,
|
||||
on_timepos_change: Callable[[Optional[float]], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
player_device_change_callback: Callable[[PlayerDeviceEvent], None],
|
||||
config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]],
|
||||
):
|
||||
self.on_timepos_change = on_timepos_change
|
||||
self.on_track_end = on_track_end
|
||||
self.config = config
|
||||
self.players: Dict[Type, Any] = {}
|
||||
self.device_id_type_map: Dict[str, Type] = {}
|
||||
self._current_device_id: Optional[str] = None
|
||||
|
||||
def player_event_wrapper(pe: PlayerEvent):
|
||||
if pe.device_id == self._current_device_id:
|
||||
on_player_event(pe)
|
||||
|
||||
self.on_player_event = player_event_wrapper
|
||||
|
||||
def callback_wrapper(pde: PlayerDeviceEvent):
|
||||
self.device_id_type_map[pde.id] = pde.player_type
|
||||
player_device_change_callback(pde)
|
||||
|
||||
self.player_device_change_callback = callback_wrapper
|
||||
|
||||
self.players = {
|
||||
player_type: player_type(
|
||||
self.on_timepos_change,
|
||||
self.on_track_end,
|
||||
self.on_player_event,
|
||||
self.player_device_change_callback,
|
||||
self.config.get(player_type.name),
|
||||
)
|
||||
for player_type in PlayerManager.available_player_types
|
||||
}
|
||||
|
||||
def change_settings(
|
||||
self, config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]],
|
||||
):
|
||||
self.config = config
|
||||
for player_type, player in self.players.items():
|
||||
player.change_settings(config.get(player_type.name))
|
||||
|
||||
def refresh_players(self):
|
||||
for player in self.players.values():
|
||||
player.refresh_players()
|
||||
|
||||
def shutdown(self):
|
||||
for p in self.players.values():
|
||||
p.shutdown()
|
||||
|
||||
def _get_current_player_type(self) -> Any:
|
||||
device_id = self._current_device_id
|
||||
if device_id:
|
||||
return self.device_id_type_map.get(device_id)
|
||||
|
||||
def _get_current_player(self) -> Any:
|
||||
if current_player_type := self._get_current_player_type():
|
||||
return self.players.get(current_player_type)
|
||||
|
||||
@property
|
||||
def can_start_playing_with_no_latency(self) -> bool:
|
||||
if self._current_device_id:
|
||||
return self._get_current_player_type().can_start_playing_with_no_latency
|
||||
else:
|
||||
return False
|
||||
|
||||
@property
|
||||
def current_device_id(self) -> Optional[str]:
|
||||
return self._current_device_id
|
||||
|
||||
def set_current_device_id(self, device_id: str):
|
||||
logging.info(f"Setting current device id to '{device_id}'")
|
||||
if cp := self._get_current_player():
|
||||
cp.pause()
|
||||
cp.song_loaded = False
|
||||
|
||||
self._current_device_id = device_id
|
||||
|
||||
if cp := self._get_current_player():
|
||||
cp.set_current_device_id(device_id)
|
||||
cp.song_loaded = False
|
||||
|
||||
def reset(self):
|
||||
if current_player := self._get_current_player():
|
||||
current_player.reset()
|
||||
|
||||
@property
|
||||
def song_loaded(self) -> bool:
|
||||
if current_player := self._get_current_player():
|
||||
return current_player.song_loaded
|
||||
return False
|
||||
|
||||
@property
|
||||
def playing(self) -> bool:
|
||||
if current_player := self._get_current_player():
|
||||
return current_player.playing
|
||||
return False
|
||||
|
||||
def get_volume(self) -> float:
|
||||
if current_player := self._get_current_player():
|
||||
return current_player.get_volume()
|
||||
return 100
|
||||
|
||||
def set_volume(self, volume: float):
|
||||
if current_player := self._get_current_player():
|
||||
current_player.set_volume(volume)
|
||||
|
||||
def get_is_muted(self) -> bool:
|
||||
if current_player := self._get_current_player():
|
||||
return current_player.get_is_muted()
|
||||
return False
|
||||
|
||||
def set_muted(self, muted: bool):
|
||||
if current_player := self._get_current_player():
|
||||
current_player.set_muted(muted)
|
||||
|
||||
def play_media(self, uri: str, progress: timedelta, song: Song):
|
||||
if current_player := self._get_current_player():
|
||||
current_player.play_media(uri, progress, song)
|
||||
|
||||
def pause(self):
|
||||
if current_player := self._get_current_player():
|
||||
current_player.pause()
|
||||
|
||||
def toggle_play(self):
|
||||
if current_player := self._get_current_player():
|
||||
if self.playing:
|
||||
current_player.pause()
|
||||
else:
|
||||
current_player.play()
|
||||
|
||||
def seek(self, position: timedelta):
|
||||
if current_player := self._get_current_player():
|
||||
current_player.seek(position)
|
140
sublime/players/mpv.py
Normal file
140
sublime/players/mpv.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
from typing import Callable, cast, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import mpv
|
||||
|
||||
from sublime.adapters.api_objects import Song
|
||||
|
||||
from .base import Player, PlayerDeviceEvent, PlayerEvent
|
||||
|
||||
REPLAY_GAIN_KEY = "Replay Gain"
|
||||
|
||||
|
||||
class MPVPlayer(Player):
|
||||
enabled = True
|
||||
name = "Local Playback"
|
||||
can_start_playing_with_no_latency = True
|
||||
supported_schemes = {"http", "https", "file"}
|
||||
song_loaded = False
|
||||
|
||||
_progress_value_lock = threading.Lock()
|
||||
_progress_value_count = 0
|
||||
|
||||
_volume = 100.0
|
||||
_muted = False
|
||||
|
||||
_is_mock = False
|
||||
|
||||
@staticmethod
|
||||
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
|
||||
return {REPLAY_GAIN_KEY: ("Disabled", "Track", "Album")}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_timepos_change: Callable[[Optional[float]], None],
|
||||
on_track_end: Callable[[], None],
|
||||
on_player_event: Callable[[PlayerEvent], None],
|
||||
player_device_change_callback: Callable[[PlayerDeviceEvent], None],
|
||||
config: Dict[str, Union[str, int, bool]],
|
||||
):
|
||||
self.mpv = mpv.MPV()
|
||||
if MPVPlayer._is_mock:
|
||||
self.mpv.audio_device = "null"
|
||||
self.mpv.audio_client_name = "sublime-music"
|
||||
self.change_settings(config)
|
||||
|
||||
@self.mpv.property_observer("time-pos")
|
||||
def time_observer(_, value: Optional[float]):
|
||||
on_timepos_change(value)
|
||||
if value is None and self._progress_value_count > 1:
|
||||
on_track_end()
|
||||
with self._progress_value_lock:
|
||||
self._progress_value_count = 0
|
||||
|
||||
if value:
|
||||
with self._progress_value_lock:
|
||||
self._progress_value_count += 1
|
||||
|
||||
@self.mpv.property_observer("demuxer-cache-time")
|
||||
def cache_size_observer(_, value: Optional[float]):
|
||||
on_player_event(
|
||||
PlayerEvent(
|
||||
PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE,
|
||||
"this device",
|
||||
stream_cache_duration=value,
|
||||
)
|
||||
)
|
||||
|
||||
# Indicate to the UI that we exist.
|
||||
player_device_change_callback(
|
||||
PlayerDeviceEvent(
|
||||
PlayerDeviceEvent.Delta.ADD, type(self), "this device", "This Device"
|
||||
)
|
||||
)
|
||||
|
||||
def change_settings(self, config: Dict[str, Union[str, int, bool]]):
|
||||
self.config = config
|
||||
self.mpv.replaygain = {
|
||||
"Disabled": "no",
|
||||
"Track": "track",
|
||||
"Album": "album",
|
||||
}.get(cast(str, config.get(REPLAY_GAIN_KEY, "Disabled")), "no")
|
||||
|
||||
def refresh_players(self):
|
||||
# Don't do anything
|
||||
pass
|
||||
|
||||
def set_current_device_id(self, device_id: str):
|
||||
# Don't do anything beacuse it should always be the "this device" ID.
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def reset(self):
|
||||
self.song_loaded = False
|
||||
with self._progress_value_lock:
|
||||
self._progress_value_count = 0
|
||||
|
||||
@property
|
||||
def playing(self) -> bool:
|
||||
return not self.mpv.pause
|
||||
|
||||
def get_volume(self) -> float:
|
||||
return self._volume
|
||||
|
||||
def set_volume(self, volume: float):
|
||||
if not self._muted:
|
||||
self.mpv.volume = volume
|
||||
self._volume = volume
|
||||
|
||||
def get_is_muted(self) -> bool:
|
||||
return self._muted
|
||||
|
||||
def set_muted(self, muted: bool):
|
||||
self.mpv.volume = 0 if muted else self._volume
|
||||
self._muted = muted
|
||||
|
||||
def play_media(self, uri: str, progress: timedelta, song: Song):
|
||||
with self._progress_value_lock:
|
||||
self._progress_value_count = 0
|
||||
|
||||
options = {
|
||||
"force-seekable": "yes",
|
||||
"start": str(progress.total_seconds()),
|
||||
}
|
||||
self.mpv.command(
|
||||
"loadfile", uri, "replace", ",".join(f"{k}={v}" for k, v in options.items())
|
||||
)
|
||||
self.mpv.pause = False
|
||||
self.song_loaded = True
|
||||
|
||||
def pause(self):
|
||||
self.mpv.pause = True
|
||||
|
||||
def play(self):
|
||||
self.mpv.pause = False
|
||||
|
||||
def seek(self, position: timedelta):
|
||||
self.mpv.seek(str(position.total_seconds()), "absolute")
|
@@ -207,6 +207,11 @@ entry.invalid {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#device-type-section-title {
|
||||
margin: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#up-next-popover #label {
|
||||
margin: 10px;
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
from .album_with_songs import AlbumWithSongs
|
||||
from .edit_form_dialog import EditFormDialog
|
||||
from .icon_button import IconButton, IconMenuButton, IconToggleButton
|
||||
from .load_error import LoadError
|
||||
from .song_list_column import SongListColumn
|
||||
@@ -7,7 +6,6 @@ from .spinner_image import SpinnerImage
|
||||
|
||||
__all__ = (
|
||||
"AlbumWithSongs",
|
||||
"EditFormDialog",
|
||||
"IconButton",
|
||||
"IconMenuButton",
|
||||
"IconToggleButton",
|
||||
|
@@ -1,160 +0,0 @@
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
TextFieldDescription = Tuple[str, str, bool]
|
||||
BooleanFieldDescription = Tuple[str, str]
|
||||
NumericFieldDescription = Tuple[str, str, Tuple[int, int, int], int]
|
||||
OptionFieldDescription = Tuple[str, str, Tuple[str, ...]]
|
||||
|
||||
|
||||
# TODO (#233) get rid of this and just make a nice custom one for Playlists since I am
|
||||
# not using this anywhere else anymore.
|
||||
class EditFormDialog(Gtk.Dialog):
|
||||
entity_name: str
|
||||
title: str
|
||||
initial_size: Tuple[int, int]
|
||||
text_fields: List[TextFieldDescription] = []
|
||||
boolean_fields: List[BooleanFieldDescription] = []
|
||||
numeric_fields: List[NumericFieldDescription] = []
|
||||
option_fields: List[OptionFieldDescription] = []
|
||||
extra_label: Optional[str] = None
|
||||
extra_buttons: List[Gtk.Button] = []
|
||||
|
||||
def get_object_name(self, obj: Any) -> str:
|
||||
"""
|
||||
Gets the friendly object name. Can be overridden.
|
||||
"""
|
||||
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)
|
||||
if not title:
|
||||
if editing:
|
||||
title = f"Edit {self.get_object_name(existing_object)}"
|
||||
else:
|
||||
title = f"Create New {self.entity_name}"
|
||||
|
||||
Gtk.Dialog.__init__(
|
||||
self, title=title, transient_for=parent, flags=0,
|
||||
)
|
||||
if not existing_object:
|
||||
existing_object = self.get_default_object()
|
||||
|
||||
self.set_default_size(*self.initial_size)
|
||||
|
||||
# Store a map of field label to GTK component.
|
||||
self.data = {}
|
||||
|
||||
content_area = self.get_content_area()
|
||||
content_grid = Gtk.Grid(
|
||||
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.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,
|
||||
)
|
||||
if is_password:
|
||||
entry.set_visibility(False)
|
||||
content_grid.attach(entry, 1, i, 1, 1)
|
||||
self.data[value_field_name] = entry
|
||||
|
||||
i += 1
|
||||
|
||||
for label, value_field_name, options in self.option_fields:
|
||||
entry_label = Gtk.Label(label=label + ":")
|
||||
entry_label.set_halign(Gtk.Align.START)
|
||||
content_grid.attach(entry_label, 0, i, 1, 1)
|
||||
|
||||
options_store = Gtk.ListStore(str)
|
||||
for option in options:
|
||||
options_store.append([option])
|
||||
|
||||
combo = Gtk.ComboBox.new_with_model(options_store)
|
||||
combo.set_id_column(0)
|
||||
renderer_text = Gtk.CellRendererText()
|
||||
combo.pack_start(renderer_text, True)
|
||||
combo.add_attribute(renderer_text, "text", 0)
|
||||
|
||||
field_value = getattr(existing_object, value_field_name)
|
||||
if field_value:
|
||||
combo.set_active(field_value.value)
|
||||
|
||||
content_grid.attach(combo, 1, i, 1, 1)
|
||||
self.data[value_field_name] = combo
|
||||
|
||||
i += 1
|
||||
|
||||
# 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.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)
|
||||
)
|
||||
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 + ":")
|
||||
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.
|
||||
spin_button = Gtk.SpinButton.new_with_range(*range_config)
|
||||
spin_button.set_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
|
||||
|
||||
if self.extra_label:
|
||||
label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
label_box.add(self.extra_label)
|
||||
content_grid.attach(label_box, 0, i, 2, 1)
|
||||
i += 1
|
||||
|
||||
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
for button, response_id in self.extra_buttons:
|
||||
if response_id is None:
|
||||
button_box.add(button)
|
||||
button.set_margin_right(10)
|
||||
else:
|
||||
self.add_action_widget(button, response_id)
|
||||
|
||||
content_grid.attach(button_box, 0, i, 2, 1)
|
||||
|
||||
content_area.pack_start(content_grid, True, True, 10)
|
||||
|
||||
self.add_buttons(
|
||||
Gtk.STOCK_CANCEL,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_EDIT if editing else Gtk.STOCK_ADD,
|
||||
Gtk.ResponseType.OK,
|
||||
)
|
||||
|
||||
self.show_all()
|
@@ -9,7 +9,8 @@ from sublime.adapters import (
|
||||
DownloadProgress,
|
||||
Result,
|
||||
)
|
||||
from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType
|
||||
from sublime.config import AppConfiguration, ProviderConfiguration
|
||||
from sublime.players import PlayerManager
|
||||
from sublime.ui import albums, artists, browse, player_controls, playlists, util
|
||||
from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage
|
||||
|
||||
@@ -110,7 +111,12 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
current_notification_hash = None
|
||||
current_other_providers: Tuple[ProviderConfiguration, ...] = ()
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
def update(
|
||||
self,
|
||||
app_config: AppConfiguration,
|
||||
player_manager: PlayerManager,
|
||||
force: bool = False,
|
||||
):
|
||||
notification = app_config.state.current_notification
|
||||
if notification and (h := hash(notification)) != self.current_notification_hash:
|
||||
self.current_notification_hash = h
|
||||
@@ -196,13 +202,138 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
# Main Settings
|
||||
self.notification_switch.set_active(app_config.song_play_notification)
|
||||
|
||||
# MPV Settings
|
||||
self.replay_gain_options.set_active_id(app_config.replay_gain.as_string())
|
||||
# Player settings
|
||||
for c in self.player_settings_box.get_children():
|
||||
self.player_settings_box.remove(c)
|
||||
|
||||
# Chromecast Settings
|
||||
self.serve_over_lan_switch.set_active(app_config.serve_over_lan)
|
||||
self.port_number_entry.set_value(app_config.port_number)
|
||||
self.port_number_entry.set_sensitive(app_config.serve_over_lan)
|
||||
def emit_player_settings_change(
|
||||
player_name: str, option_name: str, value_extraction_fn: Callable, *args
|
||||
):
|
||||
if self._updating_settings:
|
||||
return
|
||||
self.emit(
|
||||
"refresh-window",
|
||||
{
|
||||
"__player_setting__": (
|
||||
player_name,
|
||||
option_name,
|
||||
value_extraction_fn(*args),
|
||||
)
|
||||
},
|
||||
False,
|
||||
)
|
||||
|
||||
for player_name, options in player_manager.get_configuration_options().items():
|
||||
self.player_settings_box.add(Gtk.Separator())
|
||||
self.player_settings_box.add(
|
||||
self._create_label(
|
||||
f"{player_name} Settings", name="menu-settings-separator"
|
||||
)
|
||||
)
|
||||
|
||||
for option_name, descriptor in options.items():
|
||||
setting_box = Gtk.Box()
|
||||
setting_box.add(option_name_label := Gtk.Label(label=option_name))
|
||||
option_name_label.get_style_context().add_class("menu-label")
|
||||
|
||||
option_value = app_config.player_config.get(player_name, {}).get(
|
||||
option_name
|
||||
)
|
||||
|
||||
if type(descriptor) == tuple:
|
||||
option_store = Gtk.ListStore(str)
|
||||
for option in descriptor:
|
||||
option_store.append([option])
|
||||
combo = Gtk.ComboBox.new_with_model(option_store)
|
||||
combo.set_id_column(0)
|
||||
renderer_text = Gtk.CellRendererText()
|
||||
combo.pack_start(renderer_text, True)
|
||||
combo.add_attribute(renderer_text, "text", 0)
|
||||
combo.set_active_id(option_value)
|
||||
combo.connect(
|
||||
"changed",
|
||||
partial(
|
||||
emit_player_settings_change,
|
||||
player_name,
|
||||
option_name,
|
||||
lambda c: c.get_active_id(),
|
||||
),
|
||||
)
|
||||
|
||||
setting_box.pack_end(combo, False, False, 0)
|
||||
|
||||
elif descriptor == bool:
|
||||
switch = Gtk.Switch(active=option_value)
|
||||
switch.connect(
|
||||
"notify::active",
|
||||
partial(
|
||||
emit_player_settings_change,
|
||||
player_name,
|
||||
option_name,
|
||||
lambda s, _: s.get_active(),
|
||||
),
|
||||
)
|
||||
setting_box.pack_end(switch, False, False, 0)
|
||||
|
||||
elif descriptor == int:
|
||||
int_editor_box = Gtk.Box()
|
||||
|
||||
def restrict_to_ints(
|
||||
entry: Gtk.Entry, text: str, length: int, position: int
|
||||
) -> bool:
|
||||
if self._updating_settings:
|
||||
return False
|
||||
if not text.isdigit():
|
||||
entry.emit_stop_by_name("insert-text")
|
||||
return True
|
||||
return False
|
||||
|
||||
entry = Gtk.Entry(width_chars=8, text=option_value, sensitive=False)
|
||||
entry.connect("insert-text", restrict_to_ints)
|
||||
int_editor_box.add(entry)
|
||||
|
||||
buttons_box = Gtk.Box()
|
||||
|
||||
edit_button = IconButton("document-edit-symbolic", relief=True)
|
||||
confirm_button = IconButton("object-select-symbolic", relief=True)
|
||||
cancel_button = IconButton("process-stop-symbolic", relief=True)
|
||||
|
||||
def on_edit_button_click(*a):
|
||||
entry.set_sensitive(True)
|
||||
buttons_box.remove(edit_button)
|
||||
buttons_box.add(cancel_button)
|
||||
buttons_box.add(confirm_button)
|
||||
buttons_box.show_all()
|
||||
|
||||
def on_cancel_button_click(*a):
|
||||
entry.set_text(str(option_value))
|
||||
entry.set_sensitive(False)
|
||||
buttons_box.remove(cancel_button)
|
||||
buttons_box.remove(confirm_button)
|
||||
buttons_box.add(edit_button)
|
||||
buttons_box.show_all()
|
||||
|
||||
edit_button.connect("clicked", on_edit_button_click)
|
||||
confirm_button.connect(
|
||||
"clicked",
|
||||
partial(
|
||||
emit_player_settings_change,
|
||||
player_name,
|
||||
option_name,
|
||||
lambda b: int(entry.get_text()),
|
||||
),
|
||||
)
|
||||
cancel_button.connect("clicked", on_cancel_button_click)
|
||||
buttons_box.add(edit_button)
|
||||
|
||||
int_editor_box.add(buttons_box)
|
||||
|
||||
setting_box.pack_end(int_editor_box, False, False, 0)
|
||||
|
||||
setting_box.get_style_context().add_class("menu-button")
|
||||
self.player_settings_box.add(setting_box)
|
||||
|
||||
self.player_settings_box.show_all()
|
||||
|
||||
# Download Settings
|
||||
allow_song_downloads = app_config.allow_song_downloads
|
||||
@@ -674,49 +805,8 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
|
||||
# PLAYER SETTINGS
|
||||
# ==============================================================================
|
||||
vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
||||
vbox.add(
|
||||
self._create_label(
|
||||
"Local Playback Settings", name="menu-settings-separator"
|
||||
)
|
||||
)
|
||||
|
||||
# Replay Gain
|
||||
replay_gain_box = Gtk.Box()
|
||||
replay_gain_box.add(replay_gain_label := Gtk.Label(label="Replay Gain"))
|
||||
replay_gain_label.get_style_context().add_class("menu-label")
|
||||
|
||||
replay_gain_option_store = Gtk.ListStore(str, str)
|
||||
for id, option in (("no", "Disabled"), ("track", "Track"), ("album", "Album")):
|
||||
replay_gain_option_store.append([id, option])
|
||||
|
||||
self.replay_gain_options = Gtk.ComboBox.new_with_model(replay_gain_option_store)
|
||||
self.replay_gain_options.set_id_column(0)
|
||||
renderer_text = Gtk.CellRendererText()
|
||||
self.replay_gain_options.pack_start(renderer_text, True)
|
||||
self.replay_gain_options.add_attribute(renderer_text, "text", 1)
|
||||
self.replay_gain_options.connect("changed", self._on_replay_gain_change)
|
||||
|
||||
replay_gain_box.pack_end(self.replay_gain_options, False, False, 0)
|
||||
replay_gain_box.get_style_context().add_class("menu-button")
|
||||
vbox.add(replay_gain_box)
|
||||
|
||||
vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
||||
vbox.add(
|
||||
self._create_label("Chromecast Settings", name="menu-settings-separator")
|
||||
)
|
||||
|
||||
# Serve Local Files to Chromecast
|
||||
serve_over_lan, self.serve_over_lan_switch = self._create_toggle_menu_button(
|
||||
"Serve Local Files to Chromecasts on the LAN", "serve_over_lan"
|
||||
)
|
||||
vbox.add(serve_over_lan)
|
||||
|
||||
# Server Port
|
||||
server_port_box, self.port_number_entry = self._create_spin_button_menu_item(
|
||||
"LAN Server Port Number", 8000, 9000, 1, "port_number"
|
||||
)
|
||||
vbox.add(server_port_box)
|
||||
self.player_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
vbox.add(self.player_settings_box)
|
||||
|
||||
# DOWNLOAD SETTINGS
|
||||
# ==============================================================================
|
||||
@@ -877,11 +967,6 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
self.main_menu_popover.popup()
|
||||
self.main_menu_popover.show_all()
|
||||
|
||||
def _on_replay_gain_change(self, combo: Gtk.ComboBox):
|
||||
self._emit_settings_change(
|
||||
{"replay_gain": ReplayGainType.from_string(combo.get_active_id())}
|
||||
)
|
||||
|
||||
def _on_search_entry_focus(self, *args):
|
||||
self._show_search()
|
||||
|
||||
|
@@ -1,16 +1,15 @@
|
||||
import copy
|
||||
import math
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
|
||||
from pychromecast import Chromecast
|
||||
|
||||
from sublime.adapters import AdapterManager, Result, SongCacheStatus
|
||||
from sublime.adapters.api_objects import Song
|
||||
from sublime.config import AppConfiguration
|
||||
from sublime.players import ChromecastPlayer
|
||||
from sublime.ui import util
|
||||
from sublime.ui.common import IconButton, IconToggleButton, SpinnerImage
|
||||
from sublime.ui.state import RepeatType
|
||||
@@ -44,10 +43,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
current_device = None
|
||||
current_playing_index: Optional[int] = None
|
||||
current_play_queue: Tuple[str, ...] = ()
|
||||
chromecasts: List[ChromecastPlayer] = []
|
||||
cover_art_update_order_token = 0
|
||||
play_queue_update_order_token = 0
|
||||
devices_requested = False
|
||||
offline_mode = False
|
||||
|
||||
def __init__(self):
|
||||
@@ -64,8 +61,12 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.set_center_widget(playback_controls)
|
||||
self.pack_end(play_queue_volume)
|
||||
|
||||
connecting_to_device_token = 0
|
||||
connecting_icon_index = 0
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self.current_device = app_config.state.current_device
|
||||
self.update_device_list(app_config)
|
||||
|
||||
duration = (
|
||||
app_config.state.current_song.duration
|
||||
@@ -116,11 +117,29 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.play_button.set_sensitive(has_current_song)
|
||||
self.next_button.set_sensitive(has_current_song and has_next_song)
|
||||
|
||||
self.device_button.set_icon(
|
||||
"chromecast{}-symbolic".format(
|
||||
"" if app_config.state.current_device == "this device" else "-connected"
|
||||
)
|
||||
)
|
||||
self.connecting_to_device = app_config.state.connecting_to_device
|
||||
|
||||
def cycle_connecting(connecting_to_device_token: int):
|
||||
if (
|
||||
self.connecting_to_device_token != connecting_to_device_token
|
||||
or not self.connecting_to_device
|
||||
):
|
||||
return
|
||||
icon = f"chromecast-connecting-{self.connecting_icon_index}-symbolic"
|
||||
self.device_button.set_icon(icon)
|
||||
self.connecting_icon_index = (self.connecting_icon_index + 1) % 3
|
||||
GLib.timeout_add(350, cycle_connecting, connecting_to_device_token)
|
||||
|
||||
icon = ""
|
||||
if app_config.state.connecting_to_device:
|
||||
icon = "-connecting-0"
|
||||
self.connecting_icon_index = 0
|
||||
self.connecting_to_device_token += 1
|
||||
GLib.timeout_add(350, cycle_connecting, self.connecting_to_device_token)
|
||||
elif app_config.state.current_device != "this device":
|
||||
icon = "-connected"
|
||||
|
||||
self.device_button.set_icon(f"chromecast{icon}-symbolic")
|
||||
|
||||
# Volume button and slider
|
||||
if app_config.state.is_muted:
|
||||
@@ -173,9 +192,6 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.album_name.set_markup("")
|
||||
self.artist_name.set_markup("")
|
||||
|
||||
if self.devices_requested:
|
||||
self.update_device_list()
|
||||
|
||||
# Short circuit if no changes to the play queue
|
||||
force |= self.offline_mode != app_config.offline_mode
|
||||
self.offline_mode = app_config.offline_mode
|
||||
@@ -383,62 +399,63 @@ class PlayerControls(Gtk.ActionBar):
|
||||
{"no_reshuffle": True},
|
||||
)
|
||||
|
||||
def update_device_list(self, force: bool = False):
|
||||
self.device_list_loading.show()
|
||||
_current_player_id = None
|
||||
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
|
||||
|
||||
def chromecast_callback(chromecasts: List[Chromecast]):
|
||||
self.chromecasts = chromecasts
|
||||
for c in self.chromecast_device_list.get_children():
|
||||
self.chromecast_device_list.remove(c)
|
||||
def update_device_list(self, app_config: AppConfiguration):
|
||||
if (
|
||||
self._current_available_players == app_config.state.available_players
|
||||
and self._current_player_id == app_config.state.current_device
|
||||
):
|
||||
return
|
||||
|
||||
if self.current_device == "this device":
|
||||
self.this_device.set_icon("audio-volume-high-symbolic")
|
||||
else:
|
||||
self.this_device.set_icon(None)
|
||||
self._current_player_id = app_config.state.current_device
|
||||
self._current_available_players = copy.deepcopy(
|
||||
app_config.state.available_players
|
||||
)
|
||||
for c in self.device_list.get_children():
|
||||
self.device_list.remove(c)
|
||||
|
||||
chromecasts.sort(key=lambda c: c.device.friendly_name)
|
||||
for cc in chromecasts:
|
||||
for i, (player_type, players) in enumerate(
|
||||
app_config.state.available_players.items()
|
||||
):
|
||||
if len(players) == 0:
|
||||
continue
|
||||
if i > 0:
|
||||
self.device_list.add(
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
)
|
||||
self.device_list.add(
|
||||
Gtk.Label(
|
||||
label=f"{player_type.name} Devices",
|
||||
halign=Gtk.Align.START,
|
||||
name="device-type-section-title",
|
||||
)
|
||||
)
|
||||
|
||||
for player_id, player_name in sorted(players, key=lambda p: p[1]):
|
||||
icon = (
|
||||
"audio-volume-high-symbolic"
|
||||
if str(cc.device.uuid) == self.current_device
|
||||
if player_id == self.current_device
|
||||
else None
|
||||
)
|
||||
btn = IconButton(icon, label=cc.device.friendly_name)
|
||||
btn.get_style_context().add_class("menu-button")
|
||||
btn.connect(
|
||||
button = IconButton(icon, label=player_name)
|
||||
button.get_style_context().add_class("menu-button")
|
||||
button.connect(
|
||||
"clicked",
|
||||
lambda _, uuid: self.emit("device-update", uuid),
|
||||
cc.device.uuid,
|
||||
lambda _, player_id: self.emit("device-update", player_id),
|
||||
player_id,
|
||||
)
|
||||
self.chromecast_device_list.add(btn)
|
||||
self.chromecast_device_list.show_all()
|
||||
self.device_list.add(button)
|
||||
|
||||
self.device_list_loading.hide()
|
||||
self.last_device_list_update = datetime.now()
|
||||
|
||||
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):
|
||||
future = ChromecastPlayer.get_chromecasts()
|
||||
future.add_done_callback(
|
||||
lambda f: GLib.idle_add(chromecast_callback, f.result())
|
||||
)
|
||||
else:
|
||||
chromecast_callback(self.chromecasts)
|
||||
self.device_list.show_all()
|
||||
|
||||
def on_device_click(self, _: Any):
|
||||
self.devices_requested = True
|
||||
if self.device_popover.is_visible():
|
||||
self.device_popover.popdown()
|
||||
else:
|
||||
self.device_popover.popup()
|
||||
self.device_popover.show_all()
|
||||
self.update_device_list()
|
||||
|
||||
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:
|
||||
if event.button == 3: # Right click
|
||||
@@ -655,32 +672,17 @@ class PlayerControls(Gtk.ActionBar):
|
||||
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.set_action_name("app.refresh-devices")
|
||||
device_popover_header.pack_end(refresh_devices, False, False, 0)
|
||||
|
||||
device_popover_box.add(device_popover_header)
|
||||
|
||||
device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.this_device = IconButton(
|
||||
"audio-volume-high-symbolic", label="This Device",
|
||||
)
|
||||
self.this_device.get_style_context().add_class("menu-button")
|
||||
self.this_device.connect(
|
||||
"clicked", lambda *a: self.emit("device-update", "this device")
|
||||
)
|
||||
device_list.add(self.this_device)
|
||||
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
device_list_and_loading.add(self.device_list)
|
||||
|
||||
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")
|
||||
device_list.add(self.device_list_loading)
|
||||
|
||||
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)
|
||||
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
|
||||
|
||||
self.device_popover.add(device_popover_box)
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from functools import lru_cache
|
||||
from random import randint
|
||||
from typing import Any, cast, Iterable, List, Tuple
|
||||
from typing import Any, cast, Dict, Iterable, List, Tuple
|
||||
|
||||
from fuzzywuzzy import process
|
||||
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
|
||||
@@ -9,7 +9,6 @@ from sublime.adapters import AdapterManager, api_objects as API
|
||||
from sublime.config import AppConfiguration
|
||||
from sublime.ui import util
|
||||
from sublime.ui.common import (
|
||||
EditFormDialog,
|
||||
IconButton,
|
||||
LoadError,
|
||||
SongListColumn,
|
||||
@@ -17,16 +16,63 @@ from sublime.ui.common import (
|
||||
)
|
||||
|
||||
|
||||
class EditPlaylistDialog(EditFormDialog):
|
||||
entity_name: str = "Playlist"
|
||||
initial_size = (350, 120)
|
||||
text_fields = [("Name", "name", False), ("Comment", "comment", False)]
|
||||
boolean_fields = [("Public", "public")]
|
||||
class EditPlaylistDialog(Gtk.Dialog):
|
||||
def __init__(self, parent: Any, playlist: API.Playlist):
|
||||
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
delete_playlist = Gtk.Button(label="Delete Playlist")
|
||||
self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)]
|
||||
super().__init__(*args, **kwargs)
|
||||
# HEADER
|
||||
self.header = Gtk.HeaderBar()
|
||||
self._set_title(playlist.name)
|
||||
|
||||
cancel_button = Gtk.Button(label="Cancel")
|
||||
cancel_button.connect("clicked", lambda _: self.close())
|
||||
self.header.pack_start(cancel_button)
|
||||
|
||||
self.edit_button = Gtk.Button(label="Edit")
|
||||
self.edit_button.get_style_context().add_class("suggested-action")
|
||||
self.edit_button.connect(
|
||||
"clicked", lambda *a: self.response(Gtk.ResponseType.APPLY)
|
||||
)
|
||||
self.header.pack_end(self.edit_button)
|
||||
|
||||
self.set_titlebar(self.header)
|
||||
|
||||
content_area = self.get_content_area()
|
||||
content_grid = Gtk.Grid(column_spacing=10, row_spacing=10, margin=10)
|
||||
|
||||
make_label = lambda label_text: Gtk.Label(label_text, halign=Gtk.Align.END)
|
||||
|
||||
content_grid.attach(make_label("Playlist Name"), 0, 0, 1, 1)
|
||||
self.name_entry = Gtk.Entry(text=playlist.name, hexpand=True)
|
||||
self.name_entry.connect("changed", self._on_name_change)
|
||||
content_grid.attach(self.name_entry, 1, 0, 1, 1)
|
||||
|
||||
content_grid.attach(make_label("Comment"), 0, 1, 1, 1)
|
||||
self.comment_entry = Gtk.Entry(text=playlist.comment, hexpand=True)
|
||||
content_grid.attach(self.comment_entry, 1, 1, 1, 1)
|
||||
|
||||
content_grid.attach(make_label("Public"), 0, 2, 1, 1)
|
||||
self.public_switch = Gtk.Switch(active=playlist.public, halign=Gtk.Align.START)
|
||||
content_grid.attach(self.public_switch, 1, 2, 1, 1)
|
||||
|
||||
content_area.add(content_grid)
|
||||
self.show_all()
|
||||
|
||||
def _on_name_change(self, entry: Gtk.Entry):
|
||||
text = entry.get_text()
|
||||
if len(text) > 0:
|
||||
self._set_title(text)
|
||||
self.edit_button.set_sensitive(len(text) > 0)
|
||||
|
||||
def _set_title(self, playlist_name: str):
|
||||
self.header.props.title = f"Edit {playlist_name}"
|
||||
|
||||
def get_data(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name_entry.get_text(),
|
||||
"comment": self.comment_entry.get_text(),
|
||||
"public": self.public_switch.get_active(),
|
||||
}
|
||||
|
||||
|
||||
class PlaylistsPanel(Gtk.Paned):
|
||||
@@ -673,53 +719,46 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
||||
|
||||
result = dialog.run()
|
||||
# Using ResponseType.NO as the delete event.
|
||||
if result in (Gtk.ResponseType.OK, Gtk.ResponseType.NO):
|
||||
if result == Gtk.ResponseType.OK:
|
||||
AdapterManager.update_playlist(
|
||||
self.playlist_id,
|
||||
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.
|
||||
confirm_dialog = Gtk.MessageDialog(
|
||||
transient_for=self.get_toplevel(),
|
||||
message_type=Gtk.MessageType.WARNING,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
text="Confirm deletion",
|
||||
)
|
||||
confirm_dialog.add_buttons(
|
||||
Gtk.STOCK_DELETE,
|
||||
Gtk.ResponseType.YES,
|
||||
Gtk.STOCK_CANCEL,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
)
|
||||
confirm_dialog.format_secondary_markup(
|
||||
f'Are you sure you want to delete the "{playlist.name}" playlist?'
|
||||
)
|
||||
result = confirm_dialog.run()
|
||||
confirm_dialog.destroy()
|
||||
if result == Gtk.ResponseType.YES:
|
||||
AdapterManager.delete_playlist(self.playlist_id)
|
||||
playlist_deleted = True
|
||||
else:
|
||||
# In this case, we don't want to do any invalidation of
|
||||
# anything.
|
||||
dialog.destroy()
|
||||
return
|
||||
if result not in (Gtk.ResponseType.APPLY, Gtk.ResponseType.NO):
|
||||
dialog.destroy()
|
||||
return
|
||||
|
||||
# Force a re-fresh of the view
|
||||
self.emit(
|
||||
"refresh-window",
|
||||
{
|
||||
"selected_playlist_id": None
|
||||
if playlist_deleted
|
||||
else self.playlist_id
|
||||
},
|
||||
True,
|
||||
if result == Gtk.ResponseType.APPLY:
|
||||
AdapterManager.update_playlist(self.playlist_id, **dialog.get_data())
|
||||
elif result == Gtk.ResponseType.NO:
|
||||
# Delete the playlist.
|
||||
confirm_dialog = Gtk.MessageDialog(
|
||||
transient_for=self.get_toplevel(),
|
||||
message_type=Gtk.MessageType.WARNING,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
text="Confirm deletion",
|
||||
)
|
||||
confirm_dialog.add_buttons(
|
||||
Gtk.STOCK_DELETE,
|
||||
Gtk.ResponseType.YES,
|
||||
Gtk.STOCK_CANCEL,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
)
|
||||
confirm_dialog.format_secondary_markup(
|
||||
f'Are you sure you want to delete the "{playlist.name}" playlist?'
|
||||
)
|
||||
result = confirm_dialog.run()
|
||||
confirm_dialog.destroy()
|
||||
if result == Gtk.ResponseType.YES:
|
||||
AdapterManager.delete_playlist(self.playlist_id)
|
||||
playlist_deleted = True
|
||||
else:
|
||||
# In this case, we don't want to do any invalidation of
|
||||
# anything.
|
||||
dialog.destroy()
|
||||
return
|
||||
|
||||
# Force a re-fresh of the view
|
||||
self.emit(
|
||||
"refresh-window",
|
||||
{"selected_playlist_id": None if playlist_deleted else self.playlist_id},
|
||||
True,
|
||||
)
|
||||
dialog.destroy()
|
||||
|
||||
def on_playlist_list_download_all_button_click(self, _):
|
||||
|
@@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple, Type
|
||||
|
||||
from sublime.adapters import AlbumSearchQuery
|
||||
from sublime.adapters.api_objects import Genre, Song
|
||||
@@ -14,10 +14,16 @@ 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}"
|
||||
"""
|
||||
Get the icon for the repeat type.
|
||||
|
||||
>>> RepeatType.NO_REPEAT.icon, RepeatType.REPEAT_QUEUE.icon
|
||||
('media-playlist-repeat-symbolic', 'media-playlist-repeat-symbolic')
|
||||
>>> RepeatType.REPEAT_SONG.icon
|
||||
'media-playlist-repeat-song-symbolic'
|
||||
"""
|
||||
song_str = "-song" if self == RepeatType.REPEAT_SONG else ""
|
||||
return f"media-playlist-repeat{song_str}-symbolic"
|
||||
|
||||
def as_mpris_loop_status(self) -> str:
|
||||
return ["None", "Playlist", "Track"][self.value]
|
||||
@@ -57,6 +63,9 @@ class UIState:
|
||||
song_progress: timedelta = timedelta()
|
||||
song_stream_cache_progress: Optional[timedelta] = timedelta()
|
||||
current_device: str = "this device"
|
||||
connecting_to_device: bool = False
|
||||
connected_device_name: Optional[str] = None
|
||||
available_players: Dict[Type, Set[Tuple[str, str]]] = field(default_factory=dict)
|
||||
|
||||
# UI state
|
||||
current_tab: str = "albums"
|
||||
@@ -87,6 +96,7 @@ class UIState:
|
||||
del state["song_stream_cache_progress"]
|
||||
del state["current_notification"]
|
||||
del state["playing"]
|
||||
del state["available_players"]
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: Dict[str, Any]):
|
||||
@@ -95,6 +105,12 @@ class UIState:
|
||||
self.current_notification = None
|
||||
self.playing = False
|
||||
|
||||
from sublime.players import PlayerManager
|
||||
|
||||
self.available_players = {
|
||||
pt: set() for pt in PlayerManager.available_player_types
|
||||
}
|
||||
|
||||
def migrate(self):
|
||||
pass
|
||||
|
||||
|
@@ -127,7 +127,7 @@ def test_get_song_details(adapter_manager: AdapterManager):
|
||||
# song = AdapterManager.get_song_details("1")
|
||||
# print(song)
|
||||
# assert 0
|
||||
# TODO
|
||||
# TODO (#180)
|
||||
pass
|
||||
|
||||
|
||||
@@ -178,18 +178,18 @@ def test_search_result_update():
|
||||
|
||||
|
||||
def test_search(adapter_manager: AdapterManager):
|
||||
# TODO
|
||||
# TODO (#180)
|
||||
return
|
||||
results = []
|
||||
|
||||
# TODO ingest data
|
||||
# TODO (#180) ingest data
|
||||
|
||||
def search_callback(result: SearchResult):
|
||||
results.append((result.artists, result.albums, result.songs, result.playlists))
|
||||
|
||||
AdapterManager.search("ohea", search_callback=search_callback).result()
|
||||
|
||||
# TODO test getting results from the server and updating using that
|
||||
# TODO (#180) test getting results from the server and updating using that
|
||||
while len(results) < 1:
|
||||
sleep(0.1)
|
||||
|
||||
|
@@ -178,12 +178,12 @@ def test_caching_get_playlists(cache_adapter: FilesystemAdapter):
|
||||
def test_no_caching_get_playlists(adapter: FilesystemAdapter):
|
||||
adapter.get_playlists()
|
||||
|
||||
# TODO: Create a playlist (that should be allowed only if this is acting as
|
||||
# TODO (#188): Create a playlist (that should be allowed only if this is acting as
|
||||
# a ground truth adapter)
|
||||
# cache_adapter.create_playlist()
|
||||
|
||||
adapter.get_playlists()
|
||||
# TODO: verify playlist
|
||||
# TODO (#188): verify playlist
|
||||
|
||||
|
||||
def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
|
||||
@@ -221,17 +221,35 @@ def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
|
||||
with pytest.raises(CacheMissError):
|
||||
cache_adapter.get_playlist_details("2")
|
||||
|
||||
# Now ingest the playlist list and make sure that it doesn't override the songs in
|
||||
# the first Playlist.
|
||||
cache_adapter.ingest_new_data(
|
||||
KEYS.PLAYLISTS,
|
||||
None,
|
||||
[
|
||||
SubsonicAPI.Playlist(
|
||||
"1", "foo", song_count=3, duration=timedelta(seconds=41.2)
|
||||
),
|
||||
SubsonicAPI.Playlist(
|
||||
"3", "test3", song_count=3, duration=timedelta(seconds=30)
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
playlist = cache_adapter.get_playlist_details("1")
|
||||
verify_songs(playlist.songs, MOCK_SUBSONIC_SONGS)
|
||||
|
||||
|
||||
def test_no_caching_get_playlist_details(adapter: FilesystemAdapter):
|
||||
with pytest.raises(Exception):
|
||||
adapter.get_playlist_details("1")
|
||||
|
||||
# TODO: Create a playlist (that should be allowed only if this is acting as
|
||||
# TODO (#188): Create a playlist (that should be allowed only if this is acting as
|
||||
# a ground truth adapter)
|
||||
# cache_adapter.create_playlist()
|
||||
|
||||
# adapter.get_playlist_details('1')
|
||||
# TODO: verify playlist details
|
||||
# TODO (#188): verify playlist details
|
||||
|
||||
|
||||
def test_caching_get_playlist_then_details(cache_adapter: FilesystemAdapter):
|
||||
@@ -722,7 +740,9 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
|
||||
],
|
||||
biography="this is a bio",
|
||||
music_brainz_id="mbid",
|
||||
albums=[SubsonicAPI.Album(id="1", name="Foo", artist_id="1")],
|
||||
albums=[
|
||||
SubsonicAPI.Album(id="1", name="Foo", _artist="Bar", artist_id="1")
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -5,7 +6,7 @@ import pytest
|
||||
from sublime.adapters import ConfigurationStore
|
||||
from sublime.adapters.filesystem import FilesystemAdapter
|
||||
from sublime.adapters.subsonic import SubsonicAdapter
|
||||
from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType
|
||||
from sublime.config import AppConfiguration, ProviderConfiguration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -13,13 +14,19 @@ def config_filename(tmp_path: Path):
|
||||
yield tmp_path.joinpath("config.json")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cwd():
|
||||
yield Path(__file__).parent
|
||||
|
||||
|
||||
def test_config_default_cache_location():
|
||||
config = AppConfiguration()
|
||||
assert config.cache_location == Path("~/.local/share/sublime-music").expanduser()
|
||||
|
||||
|
||||
def test_server_property():
|
||||
def test_server_property(tmp_path: Path):
|
||||
config = AppConfiguration()
|
||||
config.cache_location = tmp_path
|
||||
provider = ProviderConfiguration(
|
||||
id="1",
|
||||
name="foo",
|
||||
@@ -31,11 +38,7 @@ def test_server_property():
|
||||
config.current_provider_id = "1"
|
||||
assert config.provider == provider
|
||||
|
||||
expected_state_file_location = Path("~/.local/share").expanduser()
|
||||
expected_state_file_location = expected_state_file_location.joinpath(
|
||||
"sublime-music", "1", "state.pickle",
|
||||
)
|
||||
assert config._state_file_location == expected_state_file_location
|
||||
assert config._state_file_location == tmp_path.joinpath("1", "state.pickle",)
|
||||
|
||||
|
||||
def test_json_load_unload(config_filename: Path):
|
||||
@@ -66,24 +69,19 @@ def test_json_load_unload(config_filename: Path):
|
||||
assert original_config.provider == loaded_config.provider
|
||||
|
||||
|
||||
def test_config_migrate(config_filename: Path):
|
||||
config = AppConfiguration(
|
||||
providers={
|
||||
"1": ProviderConfiguration(
|
||||
id="1",
|
||||
name="foo",
|
||||
ground_truth_adapter_type=SubsonicAdapter,
|
||||
ground_truth_adapter_config=ConfigurationStore(),
|
||||
)
|
||||
def test_config_migrate_v5_to_v6(config_filename: Path, cwd: Path):
|
||||
shutil.copyfile(str(cwd.joinpath("mock_data/config-v5.json")), str(config_filename))
|
||||
app_config = AppConfiguration.load_from_file(config_filename)
|
||||
app_config.migrate()
|
||||
|
||||
assert app_config.version == 6
|
||||
assert app_config.player_config == {
|
||||
"Local Playback": {"Replay Gain": "track"},
|
||||
"Chromecast": {
|
||||
"Serve Local Files to Chromecasts on the LAN": True,
|
||||
"LAN Server Port Number": 6969,
|
||||
},
|
||||
current_provider_id="1",
|
||||
filename=config_filename,
|
||||
)
|
||||
config.migrate()
|
||||
|
||||
assert config.version == 5
|
||||
|
||||
|
||||
def test_replay_gain_enum():
|
||||
for rg in (ReplayGainType.NO, ReplayGainType.TRACK, ReplayGainType.ALBUM):
|
||||
assert rg == ReplayGainType.from_string(rg.as_string())
|
||||
}
|
||||
app_config.save()
|
||||
app_config2 = AppConfiguration.load_from_file(config_filename)
|
||||
assert app_config == app_config2
|
||||
|
17
tests/mock_data/config-v5.json
Normal file
17
tests/mock_data/config-v5.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"allow_song_downloads": true,
|
||||
"always_stream": false,
|
||||
"cache_location": "/home/sumner/.local/share/sublime-music",
|
||||
"concurrent_download_limit": 5,
|
||||
"current_provider_id": null,
|
||||
"download_on_stream": true,
|
||||
"filename": "/home/sumner/.config/sublime-music/config.json",
|
||||
"offline_mode": false,
|
||||
"port_number": 6969,
|
||||
"prefetch_amount": 3,
|
||||
"providers": {},
|
||||
"replay_gain": 1,
|
||||
"serve_over_lan": true,
|
||||
"song_play_notification": true,
|
||||
"version": 5
|
||||
}
|
16
tests/player_tests/chromecast_tests.py
Normal file
16
tests/player_tests/chromecast_tests.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sublime.players.chromecast import ChromecastPlayer
|
||||
|
||||
|
||||
def test_init():
|
||||
empty_fn = lambda *a, **k: None
|
||||
chromecast_player = ChromecastPlayer(
|
||||
empty_fn,
|
||||
empty_fn,
|
||||
empty_fn,
|
||||
empty_fn,
|
||||
{
|
||||
"Serve Local Files to Chromecasts on the LAN": True,
|
||||
"LAN Server Port Number": 6969,
|
||||
},
|
||||
)
|
||||
chromecast_player.shutdown()
|
BIN
tests/player_tests/mock_data/test-song.mp3
Normal file
BIN
tests/player_tests/mock_data/test-song.mp3
Normal file
Binary file not shown.
80
tests/player_tests/mpv_tests.py
Normal file
80
tests/player_tests/mpv_tests.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
|
||||
from sublime.players.mpv import MPVPlayer
|
||||
|
||||
MPVPlayer._is_mock = True
|
||||
|
||||
|
||||
def test_init():
|
||||
empty_fn = lambda *a, **k: None
|
||||
MPVPlayer(empty_fn, empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"})
|
||||
|
||||
|
||||
def is_close(expected: float, value: float, delta: float = 0.5) -> bool:
|
||||
print(f"EXPECTED: {expected}, VALUE: {value}") # noqa: T001
|
||||
return abs(value - expected) < delta
|
||||
|
||||
|
||||
def test_play():
|
||||
empty_fn = lambda *a, **k: None
|
||||
mpv_player = MPVPlayer(
|
||||
empty_fn, empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"}
|
||||
)
|
||||
|
||||
song_path = Path(__file__).parent.joinpath("mock_data/test-song.mp3")
|
||||
mpv_player.play_media(str(song_path), timedelta(seconds=10), None)
|
||||
|
||||
# Test Mute and volume
|
||||
# ==================================================================================
|
||||
# Test normal volume change.
|
||||
assert mpv_player.get_volume() == 100
|
||||
mpv_player.set_volume(70)
|
||||
assert mpv_player.get_volume() == 70
|
||||
|
||||
# Test mute
|
||||
assert not mpv_player.get_is_muted()
|
||||
mpv_player.set_muted(True)
|
||||
assert mpv_player.get_is_muted()
|
||||
|
||||
# Test volume change when muted
|
||||
mpv_player.set_volume(50)
|
||||
assert mpv_player.get_volume() == 50
|
||||
# The volume of the actual player should still be muted.
|
||||
assert mpv_player.mpv.volume == 0
|
||||
|
||||
# Unmute and the volume of the actual player should be what we set (50)
|
||||
mpv_player.set_muted(False)
|
||||
assert mpv_player.mpv.volume == 50
|
||||
|
||||
# Test Play/Pause
|
||||
# ==================================================================================
|
||||
# Test Pause
|
||||
assert mpv_player.playing
|
||||
mpv_player.pause()
|
||||
assert not mpv_player.playing
|
||||
|
||||
# Test play
|
||||
mpv_player.play()
|
||||
assert mpv_player.playing
|
||||
|
||||
# Test seek
|
||||
for _ in range(5):
|
||||
sleep(0.1)
|
||||
if is_close(10, mpv_player.mpv.time_pos):
|
||||
break
|
||||
else:
|
||||
raise Exception("Never was close")
|
||||
mpv_player.seek(timedelta(seconds=20))
|
||||
|
||||
for _ in range(5):
|
||||
sleep(0.1)
|
||||
if is_close(20, mpv_player.mpv.time_pos):
|
||||
break
|
||||
else:
|
||||
raise Exception("Never was close")
|
||||
|
||||
# Pause so that it doesn't keep playing while testing
|
||||
mpv_player.pause()
|
||||
mpv_player.shutdown()
|
Reference in New Issue
Block a user