Merge branch '139-chromecast-support-not-required'

This commit is contained in:
Sumner Evans
2020-07-11 10:15:07 -06:00
39 changed files with 2308 additions and 1587 deletions

View File

@@ -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`_.

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
__version__ = "0.10.3"
__version__ = "0.10.4"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
from .manager import PlayerDeviceEvent, PlayerEvent, PlayerManager
__all__ = (
"PlayerDeviceEvent",
"PlayerEvent",
"PlayerManager",
)

215
sublime/players/base.py Normal file
View 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.
"""

View 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
View 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
View 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")

View File

@@ -207,6 +207,11 @@ entry.invalid {
min-width: 150px;
}
#device-type-section-title {
margin: 5px;
font-style: italic;
}
#up-next-popover #label {
margin: 10px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

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

Binary file not shown.

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