Merge branch 'adapter-refactor' into 'master'

Adapter refactor

Closes #175, #174, #192, #181, #37, #157, #140, #133, and #136

See merge request sumner/sublime-music!34
This commit is contained in:
Sumner Evans
2020-05-19 21:14:34 +00:00
114 changed files with 18284 additions and 7303 deletions

23
.editorconfig Normal file
View File

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

View File

@@ -19,8 +19,9 @@ lint:
- ./cicd/install-project-deps.sh - ./cicd/install-project-deps.sh
script: script:
- pipenv run python setup.py check -mrs - pipenv run python setup.py check -mrs
- pipenv run black --check .
- pipenv run flake8 - pipenv run flake8
- pipenv run mypy sublime - pipenv run mypy sublime tests/**/*.py
- pipenv run cicd/custom_style_check.py - pipenv run cicd/custom_style_check.py
test: test:
@@ -47,6 +48,7 @@ build:
build_flatpak: build_flatpak:
image: registry.gitlab.com/sumner/sublime-music/flatpak-build:latest image: registry.gitlab.com/sumner/sublime-music/flatpak-build:latest
allow_failure: true
stage: build stage: build
script: script:
- cd flatpak - cd flatpak

View File

@@ -1,7 +1,9 @@
{ {
"python.jediEnabled": false,
"python.analysis.openFilesOnly": false,
"python.linting.pylintEnabled": false, "python.linting.pylintEnabled": false,
"python.linting.flake8Enabled": true, "python.linting.flake8Enabled": true,
"python.linting.enabled": true, "python.linting.enabled": true,
"python.linting.mypyEnabled": true, "python.linting.mypyEnabled": true,
"python.formatting.provider": "yapf" "python.formatting.provider": "black"
} }

View File

@@ -1,3 +1,22 @@
v0.9.3
======
* **Features**
* The Albums tab is now paginated with configurable page sizes.
* You can sort the Albums tab ascending or descending.
* Opening an closing an album on the Albums tab now has a nice animation.
* The amount of the song that is cached is now shown while streaming a song.
* The notification for resuming a play queue is now a non-modal
notification that pops up right above the player controls.
* This release has a ton of under-the-hood changes to make things more robust
and performant.
* The cache is now stored in a SQLite database.
* The cache is no longer reliant on Subsonic which will enable more backends
in the future.
v0.9.2 v0.9.2
====== ======

View File

@@ -16,6 +16,15 @@ Please note that as of right now, I (Sumner) am basically the only contributor
to this project, so my response time to your issue may be anywhere from instant to this project, so my response time to your issue may be anywhere from instant
to infinite. to infinite.
When reporting a bug, please be as specific as possible, and include steps to
reproduce. Additionally, you can run Sublime Music with the ``-m`` flag to
enable logging at different levels. For the most verbose logging, run Sublime
Music with ``debug`` level logging::
sublime-music -m debug
This may not be necessary, and using ``info`` may also suffice.
Code Code
==== ====
@@ -78,12 +87,46 @@ Building the flatpak
Code Style Code Style
---------- ----------
* `PEP-8`_ is to be followed **strictly**. This project follows `PEP-8`_ **strictly**. The *only* exception is maximum line
* `mypy`_ is used for type checking. length, which is 88 for this project (in accordance with ``black``'s defaults).
* ``print`` statements are not to be used except for when you actually want to Lines that contain a single string literal are allowed to extend past the
print to the terminal (which should be rare). In all other cases, the more maximum line length limit.
powerful and useful ``logging`` library should be used.
This project uses flake8, mypy, and black to do static analysis of the code and
to enforce a consistent (and as deterministic as possible) code style.
Although you can technically do all of the formatting yourself, it is
recommended that you use the following tools (they are automatically installed
if you are using pipenv). The CI process uses these to check all commits, so you
will probably want these so you don't have to wait for results of the build
before knowing if your code is the correct style.
* `flake8`_ is used for linting. The following additional plugins are also used:
* ``flake8-annotations``: enforce type annotations on function definitions.
* ``flake8-bugbear``: enforce a bunch of fairly opinionated styles.
* ``flake8-comprehensions``: enforce usage of comprehensions wherever
possible.
* ``flake8-importorder`` (with the ``edited`` import style): enforce ordering
of import statements.
* ``flake8-pep3101``: no ``%`` string formatting.
* `mypy`_ is used for type checking. All type errors must be resolved.
* `black`_ is used for auto-formatting. The CI process runs ``black --check`` to
make sure that you've run ``black`` on all files (or are just good at manually
formatting).
* ``TODO`` statements must include an associated issue number (in other words,
if you want to check in a change with outstanding TODOs, there must be an
issue associated with it to fix it).
* ``print`` statements are not allowed. Use the more powerful and useful
``logging`` library instead. In the rare case that you actually want to print
to the terminal (the ``--version`` flag for example), then just disable this
check with a ``# noqa`` or a ``# noqa: T001`` comment.
.. _black: https://github.com/psf/black
.. _`PEP-8`: https://www.python.org/dev/peps/pep-0008/ .. _`PEP-8`: https://www.python.org/dev/peps/pep-0008/
.. _mypy: http://mypy-lang.org/ .. _mypy: http://mypy-lang.org/
@@ -92,9 +135,32 @@ checks for uses of ``print``. You can run the same checks that the lint job runs
yourself with the following commands:: yourself with the following commands::
$ flake8 $ flake8
$ mypy sublime $ mypy sublime tests/**/*.py
$ black --check .
$ ./cicd/custom_style_check.py $ ./cicd/custom_style_check.py
Testing
-------
This project uses ``pytest`` for testing. Tests can be added in the docstrings
of the methods that are being tested or in the ``tests`` directory. 100% test
coverage is **not** a goal of this project, and will never be. There is a lot of
code that just doesn't need tested, or is better if just tested manually (for
example most of the UI code).
Simulating Bad Network Conditions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
One of the primary goals of this project is to be resilient to crappy network
conditions. If you have good internet, you can simulate bad internet with the
``REQUEST_DELAY`` environment variable. This environment variable should be two
values, separated by a ``,``: the lower and upper limit for the delay to add to
each network request. The delay will be a random number between the lower and
upper bounds. For example, the following will run Sublime Music and every
request will have an additional 3-5 seconds of latency::
REQUEST_DELAY=3,5 sublime-music
CI/CD Pipeline CI/CD Pipeline
-------------- --------------

11
Pipfile
View File

@@ -4,28 +4,33 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages] [dev-packages]
black = "*"
docutils = "*" docutils = "*"
flake8 = "*" flake8 = "*"
flake8-annotations = "*" flake8-annotations = "*"
flake8-bugbear = "*"
flake8-comprehensions = "*" flake8-comprehensions = "*"
flake8-import-order = "*"
flake8-pep3101 = "*" flake8-pep3101 = "*"
flake8-print = "*" flake8-print = "*"
graphviz = "*" graphviz = "*"
jedi = "*"
lxml = "*" lxml = "*"
mypy = "*" mypy = "*"
pycodestyle = "*"
pytest = "*" pytest = "*"
pytest-cov = "*" pytest-cov = "*"
rope = "*" rope = "*"
rst2html5 = "*" rst2html5 = "*"
sphinx = "*" sphinx = "*"
sphinx-autodoc-typehints = "*"
sphinx-rtd-theme = "*" sphinx-rtd-theme = "*"
termcolor = "*" termcolor = "*"
yapf = "*"
[packages] [packages]
sublime-music = {editable = true,extras = ["keyring"],path = "."} sublime-music = {editable = true,extras = ["keyring"],path = "."}
dataclasses-json = {editable = true,git = "https://github.com/lidatong/dataclasses-json",ref = "master"}
[requires] [requires]
python_version = "3.8" python_version = "3.8"
[pipenv]
allow_prereleases = true

328
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "1f998ec38046cff69f63ed51610a1ca33bef37635020695781f57684daa9da01" "sha256": "c72a092370d49b350cf6565988c36a58e5e80cf0f322a07e0f0eaa6cffe2f39f"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -101,6 +101,11 @@
], ],
"version": "==2.9.2" "version": "==2.9.2"
}, },
"dataclasses-json": {
"editable": true,
"git": "https://github.com/lidatong/dataclasses-json",
"ref": "b8b60cdaa2c3ccc8d3bcbce67e911b705c3b0b10"
},
"deepdiff": { "deepdiff": {
"hashes": [ "hashes": [
"sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4", "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4",
@@ -108,13 +113,6 @@
], ],
"version": "==4.3.2" "version": "==4.3.2"
}, },
"deprecated": {
"hashes": [
"sha256:0cf37d293a96805c6afd8b5fc525cb40f23a2cac9b2d066ac3bd4b04e72ceccc",
"sha256:55b41a15bda04c6a2c0d27dd4c2b7b81ffa6348c9cad8f077ac1978c59927ab9"
],
"version": "==1.2.9"
},
"fuzzywuzzy": { "fuzzywuzzy": {
"hashes": [ "hashes": [
"sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8", "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8",
@@ -150,34 +148,59 @@
], ],
"version": "==21.2.1" "version": "==21.2.1"
}, },
"marshmallow": {
"hashes": [
"sha256:c2673233aa21dde264b84349dc2fd1dce5f30ed724a0a00e75426734de5b84ab",
"sha256:f88fe96434b1f0f476d54224d59333eba8ca1a203a2695683c1855675c4049a7"
],
"version": "==3.6.0"
},
"marshmallow-enum": {
"hashes": [
"sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58",
"sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"
],
"version": "==1.5.1"
},
"mypy-extensions": {
"hashes": [
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
],
"version": "==0.4.3"
},
"ordered-set": { "ordered-set": {
"hashes": [ "hashes": [
"sha256:a31008c57f9c9776b12eb8841b1f61d1e4d70dfbbe8875ccfa2403c54af3d51b" "sha256:a31008c57f9c9776b12eb8841b1f61d1e4d70dfbbe8875ccfa2403c54af3d51b"
], ],
"version": "==4.0.1" "version": "==4.0.1"
}, },
"peewee": {
"hashes": [
"sha256:1269a9736865512bd4056298003aab190957afe07d2616cf22eaf56cb6398369"
],
"version": "==3.13.3"
},
"protobuf": { "protobuf": {
"hashes": [ "hashes": [
"sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab", "sha256:09e29cc89b57741ae04bbf219ec723d08544d7b908f460fc3864dc3d7e22e903",
"sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f", "sha256:1ca56aa79c774af7a50934d4f75006d278d6399a3120d804827e2fc33a56ce97",
"sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a", "sha256:1f5a80dfcd805b06ebebd81c3d691ff01db8b98172c71c41d1a3ab0e7907bff4",
"sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0", "sha256:206d1f61a092d308b367b331ab216c94328ba820e63f811fafade548e293feb8",
"sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4", "sha256:46736b7774685ad84fe4eb730d2496b925b8d6a880781ba988247119162a5278",
"sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2", "sha256:4b8886683e9a8fec0078793db58faf73e4d99704c2323780e1374e9e100a8111",
"sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee", "sha256:4ce1f4364b793a1ccdd038910379be6b3c1791ce39fc921620ac96173a9f5ae3",
"sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07", "sha256:6704d751386c15f010c991937b7b69cdce370e7a124e28451bdc3a217b4ad2e9",
"sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151", "sha256:67a41c93b016f47d404dd12304bb357654959e4b13078ecaf1ad22c2c299b3ed",
"sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a", "sha256:71e6e0dc6a1ae4daaf3b172615e0543e7b0dc2376b5c18251daf6dfc10f50676",
"sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f", "sha256:78470a2463c0499f9253a98d74c74ec0c440c276e9090f65c21480e1a5408d33",
"sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7", "sha256:bded9b237935d7e6275773b576ddbddd655f9e676a05c1ac0b24e013083adf66",
"sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956", "sha256:c980e4dcb982e37543a05fb8609029858268435e1568cb8893146f533510b229",
"sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306", "sha256:ddcddc29ba29099a548bb49dbd87fc6b049dd1dd031b3154efc4df1963a5df69",
"sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961", "sha256:ea525877ac33be8a1f6441484702d6416b731c7053bfb237ab006238584e5db4",
"sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481", "sha256:ef4f091a8b4256d8982135eeff189df18b56e5215be7cef07cf886d67daa92a9"
"sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a",
"sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80"
], ],
"version": "==3.11.3" "version": "==3.12.0rc2"
}, },
"pycairo": { "pycairo": {
"hashes": [ "hashes": [
@@ -187,10 +210,10 @@
}, },
"pychromecast": { "pychromecast": {
"hashes": [ "hashes": [
"sha256:55e6db2716eff6a36a45092a2f547cc7609d98495ad70db4ac8d90feb4964f78", "sha256:078e78dbf1ca596211c06a67b7d79ae0e3d07edaa57acd647b58a3554a9c504d",
"sha256:e59e9f85d6af04f588a22ff2381b9bd8b1cd53a691a30e698ff4266bac1a30b6" "sha256:90bfc191b2aa6de3b6941cb3635ea295a4e5aebced17070550dc953d66115814"
], ],
"version": "==5.0.0" "version": "==5.2.0"
}, },
"pycparser": { "pycparser": {
"hashes": [ "hashes": [
@@ -205,13 +228,6 @@
], ],
"version": "==3.36.1" "version": "==3.36.1"
}, },
"python-dateutil": {
"hashes": [
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"version": "==2.8.1"
},
"python-levenshtein": { "python-levenshtein": {
"hashes": [ "hashes": [
"sha256:033a11de5e3d19ea25c9302d11224e1a1898fe5abd23c61c7c360c25195e3eb1" "sha256:033a11de5e3d19ea25c9302d11224e1a1898fe5abd23c61c7c360c25195e3eb1"
@@ -262,6 +278,12 @@
], ],
"version": "==1.14.0" "version": "==1.14.0"
}, },
"stringcase": {
"hashes": [
"sha256:48a06980661908efe8d9d34eab2b6c13aefa2163b3ced26972902e3bdfd87008"
],
"version": "==1.2.0"
},
"sublime-music": { "sublime-music": {
"editable": true, "editable": true,
"extras": [ "extras": [
@@ -269,6 +291,22 @@
], ],
"path": "." "path": "."
}, },
"typing-extensions": {
"hashes": [
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5",
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae",
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"
],
"version": "==3.7.4.2"
},
"typing-inspect": {
"hashes": [
"sha256:3b98390df4d999a28cf5b35d8b333425af5da2ece8a4ea9e98f71e7591347b4f",
"sha256:8f1b1dd25908dbfd81d3bebc218011531e7ab614ba6e5bf7826d887c834afab7",
"sha256:de08f50a22955ddec353876df7b2545994d6df08a2f45d54ac8c05e530372ca0"
],
"version": "==0.6.0"
},
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
@@ -276,12 +314,6 @@
], ],
"version": "==1.25.9" "version": "==1.25.9"
}, },
"wrapt": {
"hashes": [
"sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"
],
"version": "==1.12.1"
},
"zeroconf": { "zeroconf": {
"hashes": [ "hashes": [
"sha256:51f25787c27cf7b903e6795e8763bccdaa71199f61b75af97f1bde036fa43b27", "sha256:51f25787c27cf7b903e6795e8763bccdaa71199f61b75af97f1bde036fa43b27",
@@ -298,6 +330,13 @@
], ],
"version": "==0.7.12" "version": "==0.7.12"
}, },
"appdirs": {
"hashes": [
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
],
"version": "==1.4.4"
},
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
@@ -312,6 +351,14 @@
], ],
"version": "==2.8.0" "version": "==2.8.0"
}, },
"black": {
"hashes": [
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
],
"index": "pypi",
"version": "==19.10b0"
},
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
@@ -326,6 +373,13 @@
], ],
"version": "==3.0.4" "version": "==3.0.4"
}, },
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
"version": "==7.1.2"
},
"coverage": { "coverage": {
"hashes": [ "hashes": [
"sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
@@ -370,20 +424,13 @@
"index": "pypi", "index": "pypi",
"version": "==0.16" "version": "==0.16"
}, },
"entrypoints": {
"hashes": [
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
],
"version": "==0.3"
},
"flake8": { "flake8": {
"hashes": [ "hashes": [
"sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", "sha256:6c1193b0c3f853ef763969238f6c81e9e63ace9d024518edc020d5f1d6d93195",
"sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" "sha256:ea6623797bf9a52f4c9577d780da0bb17d65f870213f7b5bcc9fca82540c31d5"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.7.9" "version": "==3.8.1"
}, },
"flake8-annotations": { "flake8-annotations": {
"hashes": [ "hashes": [
@@ -393,6 +440,14 @@
"index": "pypi", "index": "pypi",
"version": "==2.1.0" "version": "==2.1.0"
}, },
"flake8-bugbear": {
"hashes": [
"sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63",
"sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"
],
"index": "pypi",
"version": "==20.1.4"
},
"flake8-comprehensions": { "flake8-comprehensions": {
"hashes": [ "hashes": [
"sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2", "sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2",
@@ -401,6 +456,14 @@
"index": "pypi", "index": "pypi",
"version": "==3.2.2" "version": "==3.2.2"
}, },
"flake8-import-order": {
"hashes": [
"sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543",
"sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"
],
"index": "pypi",
"version": "==0.18.1"
},
"flake8-pep3101": { "flake8-pep3101": {
"hashes": [ "hashes": [
"sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512", "sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512",
@@ -445,20 +508,12 @@
], ],
"version": "==1.2.0" "version": "==1.2.0"
}, },
"jedi": {
"hashes": [
"sha256:cd60c93b71944d628ccac47df9a60fec53150de53d42dc10a7fc4b5ba6aae798",
"sha256:df40c97641cb943661d2db4c33c2e1ff75d491189423249e989bcea4464f3030"
],
"index": "pypi",
"version": "==0.17.0"
},
"jinja2": { "jinja2": {
"hashes": [ "hashes": [
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" "sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
], ],
"version": "==2.11.2" "version": "==3.0.0a1"
}, },
"lxml": { "lxml": {
"hashes": [ "hashes": [
@@ -495,41 +550,30 @@
}, },
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", "sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3",
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", "sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f"
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
], ],
"version": "==1.1.1" "version": "==2.0.0a1"
}, },
"mccabe": { "mccabe": {
"hashes": [ "hashes": [
@@ -579,12 +623,12 @@
], ],
"version": "==20.3" "version": "==20.3"
}, },
"parso": { "pathspec": {
"hashes": [ "hashes": [
"sha256:158c140fc04112dc45bca311633ae5033c2c2a7b732fa33d0955bad8152a8dd0", "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
"sha256:908e9fae2144a076d72ae4e25539143d40b8e3eafbaeae03c1bfe226f4cdf12c" "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
], ],
"version": "==0.7.0" "version": "==0.8.0"
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
@@ -602,17 +646,18 @@
}, },
"pycodestyle": { "pycodestyle": {
"hashes": [ "hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
], ],
"version": "==2.5.0" "index": "pypi",
"version": "==2.6.0"
}, },
"pyflakes": { "pyflakes": {
"hashes": [ "hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
], ],
"version": "==2.1.1" "version": "==2.2.0"
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
@@ -623,18 +668,18 @@
}, },
"pyparsing": { "pyparsing": {
"hashes": [ "hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:67199f0c41a9c702154efb0e7a8cc08accf830eb003b4d9fa42c4059002e2492",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" "sha256:700d17888d441604b0bd51535908dcb297561b040819cccde647a92439db5a2a"
], ],
"version": "==2.4.7" "version": "==3.0.0a1"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
"sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", "sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3",
"sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" "sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698"
], ],
"index": "pypi", "index": "pypi",
"version": "==5.4.1" "version": "==5.4.2"
}, },
"pytest-cov": { "pytest-cov": {
"hashes": [ "hashes": [
@@ -651,6 +696,32 @@
], ],
"version": "==2020.1" "version": "==2020.1"
}, },
"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"
],
"version": "==2020.5.14"
},
"requests": { "requests": {
"hashes": [ "hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
@@ -694,21 +765,13 @@
"index": "pypi", "index": "pypi",
"version": "==3.0.3" "version": "==3.0.3"
}, },
"sphinx-autodoc-typehints": {
"hashes": [
"sha256:27c9e6ef4f4451766ab8d08b2d8520933b97beb21c913f3df9ab2e59b56e6c6c",
"sha256:a6b3180167479aca2c4d1ed3b5cb044a70a76cccd6b38662d39288ebd9f0dff0"
],
"index": "pypi",
"version": "==1.10.3"
},
"sphinx-rtd-theme": { "sphinx-rtd-theme": {
"hashes": [ "hashes": [
"sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", "sha256:1ba9bbc8898ed8531ac8d140b4ff286d57010fb878303b2efae3303726ec821b",
"sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" "sha256:a18194ae459f6a59b0d56e4a8b4c576c0125fb9a12f2211e652b4a8133092e14"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.4.3" "version": "==0.5.0rc1"
}, },
"sphinxcontrib-applehelp": { "sphinxcontrib-applehelp": {
"hashes": [ "hashes": [
@@ -759,6 +822,13 @@
"index": "pypi", "index": "pypi",
"version": "==1.1.0" "version": "==1.1.0"
}, },
"toml": {
"hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
],
"version": "==0.10.1"
},
"typed-ast": { "typed-ast": {
"hashes": [ "hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
@@ -806,14 +876,6 @@
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
], ],
"version": "==0.1.9" "version": "==0.1.9"
},
"yapf": {
"hashes": [
"sha256:3000abee4c28daebad55da6c85f3cd07b8062ce48e2e9943c8da1b9667d48427",
"sha256:3abf61ba67cf603069710d30acbc88cfe565d907e16ad81429ae90ce9651e0c9"
],
"index": "pypi",
"version": "==0.30.0"
} }
} }
} }

View File

@@ -1,303 +0,0 @@
#! /usr/bin/env python3
"""
Autogenerates Python classes for Subsonic API objects.
This program constructs a dependency graph of all of the entities defined by a
Subsonic REST API XSD file. It then uses that graph to generate code which
represents those API objects in Python.
"""
import re
import sys
from collections import defaultdict
from typing import DefaultDict, Dict, List, Set, Tuple
from graphviz import Digraph
from lxml import etree
# Global variables.
tag_type_re = re.compile(r'\{.*\}(.*)')
element_type_re = re.compile(r'.*:(.*)')
primitive_translation_map = {
'string': 'str',
'double': 'float',
'boolean': 'bool',
'long': 'int',
'dateTime': 'datetime',
}
def render_digraph(graph: DefaultDict[str, Set[str]], filename: str):
"""
Render a graph of the form {'node_name': iterable(node_name)} to
``filename``.
"""
g = Digraph('G', filename=f'/tmp/{filename}', format='png')
for type_, deps in graph.items():
g.node(type_)
for dep in deps:
g.edge(type_, dep)
g.render()
def primitive_translate(type_str: str) -> str:
# Translate the primitive values, but default to the actual value.
return primitive_translation_map.get(type_str, type_str)
def extract_type(type_str: str) -> str:
match = element_type_re.match(type_str)
if not match:
raise Exception(f'Could not extract type from string "{type_str}"')
return primitive_translate(match.group(1))
def extract_tag_type(tag_type_str: str) -> str:
match = tag_type_re.match(tag_type_str)
if not match:
raise Exception(
f'Could not extract tag type from string "{tag_type_str}"')
return match.group(1)
def get_dependencies(xs_el: etree._Element) -> Tuple[Set[str], Dict[str, str]]:
"""
Return the types which ``xs_el`` depends on as well as the type of the
object for embedding in other objects.
"""
# If the node is a comment, the tag will be callable for some reason.
# Ignore it.
if hasattr(xs_el.tag, '__call__'):
return set(), {}
tag_type = extract_tag_type(xs_el.tag)
name = xs_el.attrib.get('name')
depends_on: Set[str] = set()
type_fields: Dict[str, str] = {}
if tag_type == 'element':
# <element>s depend on their corresponding ``type``.
# There is only one field: name -> type.
type_ = extract_type(xs_el.attrib['type'])
depends_on.add(type_)
type_fields[name] = type_
elif tag_type == 'simpleType':
# <simpleType>s do not depend on any other type (that's why they are
# simple lol).
# The fields are the ``key = "key"`` pairs for the Enum if the
# restriction type is ``enumeration``.
restriction = xs_el.getchildren()[0]
restriction_type = extract_type(restriction.attrib['base'])
if restriction_type == 'str':
restriction_children = restriction.getchildren()
if extract_tag_type(restriction_children[0].tag) == 'enumeration':
type_fields['__inherits__'] = 'Enum'
for rc in restriction_children:
rc_type = primitive_translate(rc.attrib['value'])
type_fields[rc_type] = rc_type
else:
type_fields['__inherits__'] = 'str'
else:
type_fields['__inherits__'] = restriction_type
elif tag_type == 'complexType':
# <complexType>s depend on all of the types that their children have.
for el in xs_el.getchildren():
deps, fields = get_dependencies(el)
# Genres has this.
fields['value'] = 'str'
depends_on |= deps
type_fields.update(fields)
elif tag_type == 'choice':
# <choice>s depend on all of their choices (children) types.
for choice in xs_el.getchildren():
deps, fields = get_dependencies(choice)
depends_on |= deps
type_fields.update(fields)
elif tag_type == 'attribute':
# <attribute>s depend on their corresponding ``type``.
depends_on.add(extract_type(xs_el.attrib['type']))
type_fields[name] = extract_type(xs_el.attrib['type'])
elif tag_type == 'sequence':
# <sequence>s depend on their children's types.
for el in xs_el.getchildren():
deps, fields = get_dependencies(el)
depends_on |= deps
if len(fields) < 1:
# This is a comment.
continue
name, type_ = list(fields.items())[0]
type_fields[name] = f'List[{type_}]'
elif tag_type == 'complexContent':
# <complexContent>s depend on the extension's types.
extension = xs_el.getchildren()[0]
deps, fields = get_dependencies(extension)
depends_on |= deps
type_fields.update(fields)
elif tag_type == 'extension':
# <extension>s depend on their children's types as well as the base
# type.
for el in xs_el.getchildren():
deps, fields = get_dependencies(el)
depends_on |= deps
type_fields.update(fields)
base = xs_el.attrib.get('base')
if base:
base_type = extract_type(base)
depends_on.add(base_type)
type_fields['__inherits__'] = base_type
else:
raise Exception(f'Unknown tag type {tag_type}.')
depends_on -= {'bool', 'int', 'str', 'float', 'datetime'}
return depends_on, type_fields
# Check arguments.
# =============================================================================
if len(sys.argv) < 3:
print(f'Usage: {sys.argv[0]} <schema_file> <output_file>.') # noqa: T001
sys.exit(1)
schema_file, output_file = sys.argv[1:]
# Determine who depends on what and determine what fields are on each object.
# =============================================================================
with open(schema_file) as f:
tree = etree.parse(f)
dependency_graph: DefaultDict[str, Set[str]] = defaultdict(set)
type_fields: DefaultDict[str, Dict[str, str]] = defaultdict(dict)
for xs_el in tree.getroot().getchildren():
# We don't care about the top-level xs_el. We just care about the actual
# types defined by the spec.
if hasattr(xs_el.tag, '__call__'):
continue
name = xs_el.attrib['name']
dependency_graph[name], type_fields[name] = get_dependencies(xs_el)
# Determine order to put declarations using a topological sort.
# =============================================================================
# DEBUG
render_digraph(dependency_graph, 'dependency_graph')
# DFS from the subsonic-response node while keeping track of the end time to
# determine the order in which to output the API objects to the file. (The
# order is the sort of the end time. This is slightly different than
# traditional topological sort because I think that I built my digraph the
# wrong direction, but it gives the same result, regardless.)
end_times: List[Tuple[str, int]] = []
seen: Set[str] = set()
i = 0
def dfs(g: DefaultDict[str, Set[str]], el: str):
global i
if el in seen:
return
seen.add(el)
i += 1
for child in sorted(g[el]):
dfs(g, child)
i += 1
end_times.append((el, i))
dfs(dependency_graph, 'subsonic-response')
output_order = [x[0] for x in sorted(end_times, key=lambda x: x[1])]
output_order.remove('subsonic-response')
# Create the code according to the spec that was generated earlier.
# =============================================================================
def generate_class_for_type(type_name: str) -> str:
fields = type_fields[type_name]
code = ['', '']
inherits_from = ['APIObject']
inherits = fields.get('__inherits__', '')
is_enum = 'Enum' in inherits
if inherits:
if inherits in primitive_translation_map.values() or is_enum:
inherits_from.append(inherits)
else:
# Add the fields, we can't directly inherit due to the Diamond
# Problem.
fields.update(type_fields[inherits])
format_str = ' ' + ("{} = '{}'" if is_enum else '{}: {}')
code.append(f"class {type_name}({', '.join(inherits_from)}):")
has_properties = False
for key, value in fields.items():
if key.startswith('__'):
continue
# Uppercase the key if an Enum.
key = key.upper() if is_enum else key
code.append(format_str.format(key, value))
has_properties = True
indent_str = ' {}'
if not has_properties:
code.append(indent_str.format('pass'))
# Auto-generated __eq__ and __hash__ functions if there's an ID field.
if not is_enum and has_properties and 'id' in fields:
code.append('')
code.append(indent_str.format('def __eq__(self, other: Any) -> bool:'))
code.append(indent_str.format(' return hash(self) == hash(other)'))
hash_name = inherits or type_name
code.append('')
code.append(indent_str.format('def __hash__(self) -> int:'))
code.append(
indent_str.format(f" return hash(f'{hash_name}.{{self.id}}')"))
return '\n'.join(code)
with open(output_file, 'w+') as outfile:
outfile.writelines(
'\n'.join(
[
'"""',
'WARNING: AUTOGENERATED FILE',
'This file was generated by the api_object_generator.py',
'script. Do not modify this file directly, rather modify the',
'script or run it on a new API version.',
'"""',
'',
'from datetime import datetime',
'from enum import Enum',
'from typing import Any, List',
'',
'from sublime.server.api_object import APIObject',
*map(generate_class_for_type, output_order),
]) + '\n')

View File

@@ -1,23 +1,45 @@
#! /usr/bin/env python #! /usr/bin/env python
"""
Checks for TODO comments and makes sure they have an associated issue. Formats that are
accepted are:
TODO (#1)
TODO (#1)
TODO (project#1)
TODO (namespace/project#1)
TODO (namespace/namespace/project#1)
Additionally, the TODO can be postfixed with ``:``.
"""
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Pattern
from termcolor import cprint from termcolor import cprint
todo_re = re.compile(r'#\s*TODO:?\s*') todo_re = re.compile(r"\s*#\s*TODO:?\s*")
accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)') accounted_for_todo = re.compile(r"\s*#\s*TODO:?\s*\(([\w-]+(/[\w-]+)*)?#\d+\)")
def noqa_re(error_id: str = "") -> Pattern:
return re.compile(rf"#\s*noqa(:\s*{error_id})?\s*\n$")
def eprint(*strings):
cprint(" ".join(strings), "red", end="", attrs=["bold"])
def check_file(path: Path) -> bool: def check_file(path: Path) -> bool:
print(f'Checking {path.absolute()}...') # noqa: T001 print(f"Checking {path.absolute()}...") # noqa: T001
file = path.open() file = path.open()
valid = True valid = True
for i, line in enumerate(file, start=1): for i, line in enumerate(file, start=1):
if todo_re.search(line) and not accounted_for_todo.search(line): if todo_re.match(line) and not accounted_for_todo.match(line):
cprint(f'{i}: {line}', 'red', end='', attrs=['bold']) eprint(f"{i}: {line}")
valid = False valid = False
file.close() file.close()
@@ -25,7 +47,7 @@ def check_file(path: Path) -> bool:
valid = True valid = True
for path in Path('sublime').glob('**/*.py'): for path in Path("sublime").glob("**/*.py"):
valid &= check_file(path) valid &= check_file(path)
print() # noqa: T001 print() # noqa: T001

166
docs/adapter-api.rst Normal file
View File

@@ -0,0 +1,166 @@
Adapter API
###########
Adapters are an interface between a collection of music data and metadata and
the Sublime Music UI. An adapter exposes a Subsonic-like API to the UI layer,
but can be backed by a variety of music stores including a Subsonic-compatible
server, data on the local filesystem, or even an entirely different service.
This document is designed to help you understand the Adapter API so that you can
create your own custom adapters. This document is best read in conjunction with
the :class:`sublime.adapters.Adapter` documentation. This document is meant as a
guide to tell you a general order in which to implement things.
Terms
=====
**Music Metadata**
Metadata about a music collection. This includes things like song metadata,
playlists, artists, albums, filesystem hierarchy, etc.
**Music Data**
The actual data of a music file. This may be accessed in a variety of
different ways including via a stream URL, or via the local filesystem.
**Music Source**
A source of music metadata and music data. This is the most atomic entity that
the user interacts with. It can be composed of one or two *Adapters*.
**Adapter**
A module which exposes the Adapter API.
Creating Your Adapter Class
===========================
An adapter is composed of a single Python module. The adapter module can have
arbitrary code, and as many files/classes/functions/etc. as necessary, however
there must be one and only one class in the module which inherits from the
:class:`sublime.adapters.Adapter` class. Normally, a single file with a single
class should be enough to implement the entire adapter.
.. warning::
Your adapter cannot assume that it will be running on a single thread. Due to
the nature of the GTK event loop, functions can be called from any thread at
any time. **It is critical that your adapter is thread-safe.** Failure to
make your adapter thread-safe will result in massive problems and undefined
behavior.
After you've created the class, you will want to implement the following
functions and properties first:
* ``__init__``: Used to initialize your adapter. See the
:class:`sublime.adapters.Adapter.__init__` documentation for the function
signature of the ``__init__`` function.
* ``can_service_requests``: This property which will tell the UI whether or not
your adapter can currently service requests. (See the
:class:`sublime.adapters.Adapter.can_service_requests` documentation for
examples of what you may want to check in this property.)
.. warning::
This function is called *a lot* (probably too much?) so it *must* return a
value *instantly*. **Do not** perform a network request in this function.
If your adapter depends on connection to the network use a periodic ping
that updates a state variable that this function returns.
* ``get_config_parameters``: Specifies the settings which can be configured on
for the adapter. See :ref:`adapter-api:Handling Configuration` for details.
* ``verify_configuration``: Verifies whether or not a given set of configuration
values are valid. See :ref:`adapter-api:Handling Configuration` for details.
.. tip::
While developing the adapter, setting ``can_service_requests`` to ``True``
will indicate to the UI that your adapter is always ready to service
requests. This can be a useful debugging tool.
.. note::
The :class:`sublime.adapters.Adapter` class is an `Abstract Base Class
<abc_>`_ and all required functions are annotated with the
``@abstractmethod`` decorator. This means that your adapter will fail to
instantiate if the abstract methods are not implemented.
.. _abc: https://docs.python.org/3/library/abc.html
Handling Configuration
----------------------
For each configuration parameter you want to allow your adapter to accept, you
must do the following:
1. Choose a name for your configuration parameter. The configuration parameter
name must be unique within your adapter.
2. Add a new entry to the return value of your
:class:`sublime.adapters.Adapter.get_config_parameters` function with the key
being the name from (1), and the value being a
:class:`sublime.adapters.ConfigParamDescriptor`. The order of the keys in the
dictionary matters, since the UI uses that to determine the order in which
the configuration parameters will be shown in the UI.
3. Add any verifications that are necessary for your configuration parameter in
your :class:`sublime.adapters.Adapter.verify_configuration` function. If you
parameter descriptor has ``required = True``, then that parameter is
guaranteed to appear in the configuration.
4. The configuration parameter will be passed into your
:class:`sublime.adapters.Adapter.init` function. It is guaranteed that the
``verify_configuration`` will have been called first, so there is no need to
re-verify the config that is passed.
Implementing Data Retrieval Methods
-----------------------------------
After you've done the initial configuration of your adapter class, you will want
to implement the actual adapter data retrieval functions.
For each data retrieval function there is a corresponding ``can_``-prefixed
property (CPP) which will be used by the UI to determine if the data retrieval
function can be called at the given time. If the CPP is ``False``, the UI will
never call the corresponding function (and if it does, it's a UI bug). The CPP
can be dynamic, for example, if your adapter supports many API versions, some of
the CPPs may depend on the API version.
There is a special, global ``can_``-prefixed property which determines whether
the adapter can currently service *any* requests. This should be used for checks
such as making sure that the user is able to access the server. (However, this
must be done in a non-blocking manner since this is called *a lot*.)
.. code:: python
@property
def can_service_requests(self) -> bool:
return self.cached_ping_result_is_ok()
Here is an example of what a ``get_playlists`` interface for an external server
might look:
.. code:: python
can_get_playlists = True
def get_playlists(self) -> List[Playlist]:
return my_server.get_playlists()
can_get_playlist_details = True
def get_playlist_details(self, playlist_id: str) -> PlaylistDetails:
return my_server.get_playlist(playlist_id)
.. tip::
By default, all ``can_``-prefixed properties are ``False``, which means that
you can implement them one-by-one, testing as you go. The UI should
dynamically enable features as new ``can_``-prefixed properties become
``True``.*
\* At the moment, this isn't really the case and the UI just kinda explodes
if it doesn't have some of the functions available, but in the future guards
will be added around all of the function calls.
Usage Parameters
----------------
There are a few special properties dictate how the adapter can be used. You
probably do not need to use this except for very specific purposes. Read the
"Usage Parameters" section of the source code for details.

View File

@@ -18,14 +18,15 @@
import datetime import datetime
project = 'Sublime Music' project = "Sublime Music"
copyright = f'{datetime.datetime.today().year}, Sumner Evans' copyright = f"{datetime.datetime.today().year}, Sumner Evans"
author = 'Sumner Evans' author = "Sumner Evans"
gitlab = 'https://gitlab.com/sumner/sublime-music/' gitlab_url = "https://gitlab.com/sumner/sublime-music/"
# Get the version from the package. # Get the version from the package.
import sublime import sublime
release = f'v{sublime.__version__}'
version = release = f"v{sublime.__version__}"
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
@@ -33,59 +34,71 @@ release = f'v{sublime.__version__}'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx_autodoc_typehints', "sphinx.ext.autosectionlabel",
'sphinx.ext.intersphinx', "sphinx.ext.intersphinx",
'sphinx.ext.mathjax', "sphinx.ext.mathjax",
'sphinx.ext.viewcode', "sphinx.ext.viewcode",
] ]
autodoc_default_flags = [ autodoc_default_options = {
'members', "members": True,
'undoc-members', "undoc-members": True,
'show-inheritance', "show-inheritance": True,
] "special-members": "__init__",
}
autosectionlabel_prefix_document = True
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
}
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ["_templates"]
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = "index"
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path. # This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use. # The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx' pygments_style = "sphinx"
rst_epilog = f"""
-------------------------------------------------------------------------------
.. tip::
If you have any questions or want to suggest a change to this document,
please submit an issue or MR to the `GitLab repo`_.
.. _GitLab repo: {gitlab}
"""
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
# #
html_theme = 'sphinx_rtd_theme' html_theme = "sphinx_rtd_theme"
html_logo = "../logo/logo.png"
html_theme_options = {
"logo_only": True,
}
# Edit on GitLab integration
html_context = {
"display_gitlab": True,
"gitlab_user": "sumner",
"gitlab_repo": "sublime-music",
"gitlab_version": "master",
"conf_py_path": "/docs/",
}
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ["_static"]
# -- Options for manual page output --------------------------------------- # -- Options for manual page output ---------------------------------------
man_pages = [ man_pages = [
('manpages/sublime-music', 'sublime-music', u'native GTK *sonic client', (
'Louis-Philippe Véronneau', 1) "manpages/sublime-music",
"sublime-music",
"native GTK *sonic client",
"Louis-Philippe Véronneau",
1,
)
] ]

View File

@@ -16,7 +16,7 @@ Linux Desktop.
.. _Navidrome: https://www.navidrome.org/ .. _Navidrome: https://www.navidrome.org/
.. figure:: ./_static/screenshots/play-queue.png .. figure:: ./_static/screenshots/play-queue.png
:width: 80 % :width: 80%
:align: center :align: center
:target: ./_static/screenshots/play-queue.png :target: ./_static/screenshots/play-queue.png
@@ -81,6 +81,7 @@ plain-text::
screenshots.rst screenshots.rst
settings.rst settings.rst
adapter-api.rst
api/sublime.rst api/sublime.rst
Indices and tables Indices and tables

View File

@@ -1,9 +1,9 @@
bottle==0.12.17 bottle==0.12.17
git+https://github.com/lidatong/dataclasses-json@master#egg=dataclasses-json
deepdiff==4.0.7 deepdiff==4.0.7
Deprecated==1.2.6
fuzzywuzzy==0.17.0 fuzzywuzzy==0.17.0
peewee==3.13.3
PyChromecast==3.2.3 PyChromecast==3.2.3
python-dateutil==2.8.0
python-Levenshtein==0.12.0 python-Levenshtein==0.12.0
python-mpv==0.3.9 python-mpv==0.3.9
PyYAML==5.1.2 PyYAML==5.1.2

View File

@@ -1,7 +1,9 @@
[flake8] [flake8]
ignore = E402, W503, ANN002, ANN003, ANN101, ANN102, ANN204 extend-ignore = E203, E402, E722, W503, ANN002, ANN003, ANN101, ANN102, ANN204
exclude = .git,__pycache__,build,dist,flatpak exclude = .git,__pycache__,build,dist,flatpak
max-line-length = 88
suppress-none-returning = True suppress-none-returning = True
suppress-dummy-args = True
application-import-names = sublime application-import-names = sublime
import-order-style = edited import-order-style = edited
@@ -35,24 +37,25 @@ ignore_missing_imports = True
[mypy-pychromecast] [mypy-pychromecast]
ignore_missing_imports = True ignore_missing_imports = True
[yapf] [mypy-pytest]
based_on_style = pep8 ignore_missing_imports = True
split_before_bitwise_operator = true
split_before_arithmetic_operator = true [mypy-playhouse.sqliteq]
split_before_dot = true ignore_missing_imports = True
split_before_logical_operator = true
split_complex_comprehension = true [mypy-peewee]
split_before_first_argument = true ignore_missing_imports = True
[tool:pytest] [tool:pytest]
python_files = tests/**/*.py tests/*.py python_files = tests/**/*.py tests/*.py
python_functions = test_* *_test python_functions = test_* *_test
log_cli_level = 10
addopts = addopts =
-vvv -vvv
--doctest-modules --doctest-modules
--ignore-glob='api_object_generator'
--ignore-glob='flatpak' --ignore-glob='flatpak'
--ignore-glob='cicd' --ignore-glob='cicd'
--cov=sublime --cov=sublime
--cov-report html --cov-report html
--cov-report term --cov-report term
--no-cov-on-fail

View File

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

View File

@@ -1 +1 @@
__version__ = '0.9.2' __version__ = "0.9.2"

View File

@@ -2,9 +2,11 @@
import argparse import argparse
import logging import logging
import os import os
from pathlib import Path
import gi import gi
gi.require_version('Gtk', '3.0')
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa: F401 from gi.repository import Gtk # noqa: F401
import sublime import sublime
@@ -12,56 +14,52 @@ from sublime.app import SublimeMusicApp
def main(): def main():
parser = argparse.ArgumentParser(description='Sublime Music') parser = argparse.ArgumentParser(description="Sublime Music")
parser.add_argument( parser.add_argument(
'-v', "-v", "--version", help="show version and exit", action="store_true"
'--version', )
help='show version and exit', parser.add_argument("-l", "--logfile", help="the filename to send logs to")
action='store_true', parser.add_argument(
"-m",
"--loglevel",
help="the minimum level of logging to do",
default="WARNING",
) )
parser.add_argument( parser.add_argument(
'-l', "-c",
'--logfile', "--config",
help='the filename to send logs to', help="specify a configuration file. Defaults to ~/.config/sublime-music/config.json", # noqa: 512
)
parser.add_argument(
'-m',
'--loglevel',
help='the minimum level of logging to do',
default='WARNING',
)
parser.add_argument(
'-c',
'--config',
help='specify a configuration file. Defaults to '
'~/.config/sublime-music/config.json',
) )
args, unknown_args = parser.parse_known_args() args, unknown_args = parser.parse_known_args()
if args.version: if args.version:
print(f'Sublime Music v{sublime.__version__}') # noqa: T001 print(f"Sublime Music v{sublime.__version__}") # noqa: T001
return return
min_log_level = getattr(logging, args.loglevel.upper(), None) min_log_level = getattr(logging, args.loglevel.upper(), None)
if not isinstance(min_log_level, int): if not isinstance(min_log_level, int):
logging.error(f'Invalid log level: {args.loglevel.upper()}.') logging.error(f"Invalid log level: {args.loglevel.upper()}.")
min_log_level = logging.WARNING min_log_level = logging.WARNING
logging.basicConfig( logging.basicConfig(
filename=args.logfile, filename=args.logfile,
level=min_log_level, level=min_log_level,
format='%(asctime)s:%(levelname)s:%(name)s:%(module)s:%(message)s', format="%(asctime)s:%(levelname)s:%(name)s:%(module)s:%(message)s",
) )
# Config File # Config File
config_file = args.config config_file = args.config
if not config_file: if not config_file:
# Default to ~/.config/sublime-music. # Default to ~/.config/sublime-music.
config_folder = ( config_file = (
os.environ.get('XDG_CONFIG_HOME') or os.environ.get('APPDATA') Path(
or os.path.join(os.environ.get('HOME'), '.config')) os.environ.get("XDG_CONFIG_HOME")
config_folder = os.path.join(config_folder, 'sublime-music') or os.environ.get("APPDATA")
config_file = os.path.join(config_folder, 'config.json') or os.path.join("~/.config")
)
.expanduser()
.joinpath("sublime-music", "config.yaml")
)
app = SublimeMusicApp(config_file) app = SublimeMusicApp(Path(config_file))
app.run(unknown_args) app.run(unknown_args)

View File

@@ -0,0 +1,21 @@
from .adapter_base import (
Adapter,
AlbumSearchQuery,
CacheMissError,
CachingAdapter,
ConfigParamDescriptor,
SongCacheStatus,
)
from .manager import AdapterManager, Result, SearchResult
__all__ = (
"Adapter",
"AdapterManager",
"AlbumSearchQuery",
"CacheMissError",
"CachingAdapter",
"ConfigParamDescriptor",
"Result",
"SearchResult",
"SongCacheStatus",
)

View File

@@ -0,0 +1,829 @@
import abc
import hashlib
import json
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
from pathlib import Path
from typing import (
Any,
Dict,
Iterable,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
)
from .api_objects import (
Album,
Artist,
Directory,
Genre,
Playlist,
PlayQueue,
SearchResult,
Song,
)
class SongCacheStatus(Enum):
"""
Represents the cache state of a given song.
* :class:`SongCacheStatus.NOT_CACHED` -- indicates that the song is not cached on
disk.
* :class:`SongCacheStatus.CACHED` -- indicates that the song is cached on disk.
* :class:`SongCacheStatus.PERMANENTLY_CACHED` -- indicates that the song is cached
on disk and will not be deleted when the cache gets too big.
* :class:`SongCacheStatus.DOWNLOADING` -- indicates that the song is being
downloaded.
* :class:`SongCacheStatus.CACHED_STALE` -- indicates that the song is cached on
disk, but has been invalidated.
"""
NOT_CACHED = 0
CACHED = 1
PERMANENTLY_CACHED = 2
DOWNLOADING = 3
CACHED_STALE = 4
@dataclass
class AlbumSearchQuery:
"""
Represents a query for getting albums from an adapter. The UI will request the
albums in pages.
**Fields:**
* :class:`AlbumSearchQuery.type` -- the query :class:`AlbumSearchQuery.Type`
* :class:`AlbumSearchQuery.year_range` -- (guaranteed to only exist if ``type`` is
:class:`AlbumSearchQuery.Type.YEAR_RANGE`) a tuple with the lower and upper bound
(inclusive) of the album years to return
* :class:`AlbumSearchQuery.genre` -- (guaranteed to only exist if the ``type`` is
:class:`AlbumSearchQuery.Type.GENRE`) return albums of the given genre
"""
class _Genre(Genre):
def __init__(self, name: str):
self.name = name
class Type(Enum):
"""
Represents a type of query. Use :class:`Adapter.supported_artist_query_types` to
specify what search types your adapter supports.
* :class:`AlbumSearchQuery.Type.RANDOM` -- return a random set of albums
* :class:`AlbumSearchQuery.Type.NEWEST` -- return the most recently added albums
* :class:`AlbumSearchQuery.Type.RECENT` -- return the most recently played
albums
* :class:`AlbumSearchQuery.Type.STARRED` -- return only starred albums
* :class:`AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME` -- return the albums
sorted alphabetically by album name
* :class:`AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST` -- return the albums
sorted alphabetically by artist name
* :class:`AlbumSearchQuery.Type.YEAR_RANGE` -- return albums in the given year
range
* :class:`AlbumSearchQuery.Type.GENRE` -- return songs of the given genre
"""
RANDOM = 0
NEWEST = 1
FREQUENT = 2
RECENT = 3
STARRED = 4
ALPHABETICAL_BY_NAME = 5
ALPHABETICAL_BY_ARTIST = 6
YEAR_RANGE = 7
GENRE = 8
type: Type
year_range: Tuple[int, int] = (2010, 2020)
genre: Genre = _Genre("Rock")
_strhash: Optional[str] = None
def strhash(self) -> str:
"""
Returns a deterministic hash of the query as a string.
>>> query = AlbumSearchQuery(
... AlbumSearchQuery.Type.YEAR_RANGE, year_range=(2018, 2019)
... )
>>> query.strhash()
'a6571bb7be65984c6627f545cab9fc767fce6d07'
"""
if not self._strhash:
self._strhash = hashlib.sha1(
bytes(
json.dumps((self.type.value, self.year_range, self.genre.name)),
"utf8",
)
).hexdigest()
return self._strhash
class CacheMissError(Exception):
"""
This exception should be thrown by caching adapters when the request data is not
available or is invalid. If some of the data is available, but not all of it, the
``partial_data`` parameter should be set with the partial data. If the ground truth
adapter can't service the request, or errors for some reason, the UI will try to
populate itself with the partial data returned in this exception (with the necessary
error text to inform the user that retrieval from the ground truth adapter failed).
"""
def __init__(self, *args, partial_data: Any = None):
"""
Create a :class:`CacheMissError` exception.
:param args: arguments to pass to the :class:`BaseException` base class.
:param partial_data: the actual partial data for the UI to use in case of ground
truth adapter failure.
"""
self.partial_data = partial_data
super().__init__(*args)
@dataclass
class ConfigParamDescriptor:
"""
Describes a parameter that can be used to configure an adapter. The
:class:`description`, :class:`required` and :class:`default:` should be self-evident
as to what they do.
The :class:`type` must be one of the following:
* The literal type ``str``: corresponds to a freeform text entry field in the UI.
* The literal type ``bool``: corresponds to a checkbox in the UI.
* The literal type ``int``: corresponds to a numeric input in the UI.
* The literal string ``"password"``: corresponds to a password entry field in the
UI.
* The literal string ``"option"``: corresponds to dropdown in the UI.
The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
`int`. It specifies the min and max values that the UI control can have.
The :class:`numeric_step` parameter only has an effect if the :class:`type` is
`int`. It specifies the step that will be taken using the "+" and "-" buttons on the
UI control (if supported).
The :class:`options` parameter only has an effect if the :class:`type` is
``"option"``. It specifies the list of options that will be available in the
dropdown in the UI.
"""
type: Union[Type, str]
description: str
required: bool = True
default: Any = None
numeric_bounds: Optional[Tuple[int, int]] = None
numeric_step: Optional[int] = None
options: Optional[Iterable[str]] = None
class Adapter(abc.ABC):
"""
Defines the interface for a Sublime Music Adapter.
All functions that actually retrieve data have a corresponding: ``can_``-prefixed
property (which can be dynamic) which specifies whether or not the adapter supports
that operation at the moment.
"""
# Configuration and Initialization Properties
# These properties determine how the adapter can be configured and how to
# initialize the adapter given those configuration values.
# ==================================================================================
@staticmethod
@abc.abstractmethod
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
"""
Specifies the settings which can be configured for the adapter.
:returns: An dictionary where the keys are the name of the configuration
paramter and the values are the :class:`ConfigParamDescriptor` object
corresponding to that configuration parameter. The order of the keys in the
dictionary correspond to the order that the configuration parameters will be
shown in the UI.
"""
@staticmethod
@abc.abstractmethod
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
"""
Specifies a function for verifying whether or not the config is valid.
:param config: The adapter configuration. The keys of are the configuration
parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual value of
the configuration parameter. It is guaranteed that all configuration
parameters that are marked as required will have a value in ``config``.
:returns: A dictionary containing varification errors. The keys of the returned
dictionary should be the same as the passed in via the ``config`` parameter.
The values should be strings describing why the corresponding value in the
``config`` dictionary is invalid.
Not all keys need be returned (for example, if there's no error for a given
configuration parameter), and returning `None` indicates no error.
"""
@abc.abstractmethod
def __init__(self, config: dict, data_directory: Path):
"""
This function should be overridden by inheritors of :class:`Adapter` and should
be used to do whatever setup is required for the adapter.
This should do the bare minimum to get things set up, since this blocks the main
UI loop. If you need to do longer initialization, use the :class:`initial_sync`
function.
:param config: The adapter configuration. The keys of are the configuration
parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual value of
the configuration parameter.
:param data_directory: the directory where the adapter can store data. This
directory is guaranteed to exist.
"""
@abc.abstractmethod
def initial_sync(self):
"""
Perform any operations that are required to get the adapter functioning
properly. For example, this function can be used to wait for an initial ping to
come back from the server.
"""
@abc.abstractmethod
def shutdown(self):
"""
This function is called when the app is being closed or the server is changing.
This should be used to clean up anything that is necessary such as writing a
cache to disk, disconnecting from a server, etc.
"""
# Usage Properties
# These properties determine how the adapter can be used and how quickly
# data can be expected from this adapter.
# ==================================================================================
@property
def can_be_cached(self) -> bool:
"""
Whether or not this adapter can be used as the ground-truth adapter behind a
caching adapter.
The default is ``True``, since most adapters will want to take advantage of the
built-in filesystem cache.
"""
return True
@property
def is_networked(self) -> bool:
"""
Whether or not this adapter operates over the network. This will be used to
determine whether or not some of the offline/online management features should
be enabled.
"""
return True
# Availability Properties
# These properties determine if what things the adapter can be used to do
# at the current moment.
# ==================================================================================
@property
@abc.abstractmethod
def can_service_requests(self) -> bool:
"""
Specifies whether or not the adapter can currently service requests. If this is
``False``, none of the other data retrieval functions are expected to work.
This property must be server *instantly*. This function is called *very* often,
and even a few milliseconds delay stacks up quickly and can block the UI thread.
For example, if your adapter requires access to an external service, on option
is to ping the server every few seconds and cache the result of the ping and use
that as the return value of this function.
"""
# Playlists
@property
def can_get_playlists(self) -> bool:
"""
Whether :class:`get_playlist` can be called on the adapter right now.
"""
return False
@property
def can_get_playlist_details(self) -> bool:
"""
Whether :class:`get_playlist_details` can be called on the adapter right now.
"""
return False
@property
def can_create_playlist(self) -> bool:
"""
Whether :class:`create_playlist` can be called on the adapter right now.
"""
return False
@property
def can_update_playlist(self) -> bool:
"""
Whether :class:`update_playlist` can be called on the adapter right now.
"""
return False
@property
def can_delete_playlist(self) -> bool:
"""
Whether :class:`delete_playlist` can be called on the adapter right now.
"""
return False
# Downloading/streaming cover art and songs
@property
def supported_schemes(self) -> Iterable[str]:
"""
Specifies a collection of scheme names that can be provided by the adapter for a
given resource (song or cover art) right now.
Examples of values that could be provided include ``http``, ``https``, ``file``,
or ``ftp``.
"""
# TODO (#189) actually use this
return ()
@property
def can_get_cover_art_uri(self) -> bool:
"""
Whether :class:`get_cover_art_uri` can be called on the adapter right now.
"""
@property
def can_stream(self) -> bool:
"""
Whether or not the adapter can provide a stream URI right now.
"""
return False
@property
def can_get_song_uri(self) -> bool:
"""
Whether :class:`get_song_uri` can be called on the adapter right now.
"""
return False
# Songs
@property
def can_get_song_details(self) -> bool:
"""
Whether :class:`get_song_details` can be called on the adapter right now.
"""
return False
@property
def can_scrobble_song(self) -> bool:
"""
Whether :class:`scrobble_song` can be called on the adapter right now.
"""
return False
# Artists
@property
def supported_artist_query_types(self) -> Set[AlbumSearchQuery.Type]:
"""
A set of the query types that this adapter can service.
:returns: A set of :class:`AlbumSearchQuery.Type` objects.
"""
# TODO (#203): use this
return set()
@property
def can_get_artists(self) -> bool:
"""
Whether :class:`get_aritsts` can be called on the adapter right now.
"""
return False
@property
def can_get_artist(self) -> bool:
"""
Whether :class:`get_aritst` can be called on the adapter right now.
"""
return False
@property
def can_get_ignored_articles(self) -> bool:
"""
Whether :class:`get_ignored_articles` can be called on the adapter right now.
"""
return False
# Albums
@property
def can_get_albums(self) -> bool:
"""
Whether :class:`get_albums` can be called on the adapter right now.
"""
return False
@property
def can_get_album(self) -> bool:
"""
Whether :class:`get_album` can be called on the adapter right now.
"""
return False
# Browse directories
@property
def can_get_directory(self) -> bool:
"""
Whether :class:`get_directory` can be called on the adapter right now.
"""
return False
# Genres
@property
def can_get_genres(self) -> bool:
"""
Whether :class:`get_genres` can be called on the adapter right now.
"""
return False
# Play Queue
@property
def can_get_play_queue(self) -> bool:
"""
Whether :class:`get_play_queue` can be called on the adapter right now.
"""
return False
@property
def can_save_play_queue(self) -> bool:
"""
Whether :class:`save_play_queue` can be called on the adapter right now.
"""
return False
# Search
@property
def can_search(self) -> bool:
"""
Whether :class:`search` can be called on the adapter right now.
"""
return False
# Data Retrieval Methods
# These properties determine if what things the adapter can be used to do
# at the current moment.
# ==================================================================================
def get_playlists(self) -> Sequence[Playlist]:
"""
Get a list of all of the playlists known by the adapter.
:returns: A list of all of the :class:`sublime.adapter.api_objects.Playlist`
objects known to the adapter.
"""
raise self._check_can_error("get_playlists")
def get_playlist_details(self, playlist_id: str,) -> Playlist:
"""
Get the details for the given ``playlist_id``. If the playlist_id does not
exist, then this function should throw an exception.
:param playlist_id: The ID of the playlist to retrieve.
:returns: A :class:`sublime.adapter.api_objects.Play` object for the given
playlist.
"""
raise self._check_can_error("get_playlist_details")
def create_playlist(
self, name: str, songs: Sequence[Song] = None,
) -> Optional[Playlist]:
"""
Creates a playlist of the given name with the given songs.
:param name: The human-readable name of the playlist.
:param songs: A list of songs that should be included in the playlist.
:returns: A :class:`sublime.adapter.api_objects.Playlist` object for the created
playlist. If getting this information will incurr network overhead, then
just return ``None``.
"""
raise self._check_can_error("create_playlist")
def update_playlist(
self,
playlist_id: str,
name: str = None,
comment: str = None,
public: bool = None,
song_ids: Sequence[str] = None,
) -> Playlist:
"""
Updates a given playlist. If a parameter is ``None``, then it will be ignored
and no updates will occur to that field.
:param playlist_id: The human-readable name of the playlist.
:param name: The human-readable name of the playlist.
:param comment: The playlist comment.
:param public: This is very dependent on the adapter, but if the adapter has a
shared/public vs. not shared/private playlists concept, setting this to
``True`` will make the playlist shared/public.
:param song_ids: A list of song IDs that should be included in the playlist.
:returns: A :class:`sublime.adapter.api_objects.Playlist` object for the updated
playlist.
"""
raise self._check_can_error("update_playlist")
def delete_playlist(self, playlist_id: str):
"""
Deletes the given playlist.
:param playlist_id: The human-readable name of the playlist.
"""
raise self._check_can_error("delete_playlist")
def get_cover_art_uri(self, cover_art_id: str, scheme: str, size: int) -> str:
"""
Get a URI for a given ``cover_art_id``.
:param cover_art_id: The song, album, or artist ID.
:param scheme: The URI scheme that should be returned. It is guaranteed that
``scheme`` will be one of the schemes returned by
:class:`supported_schemes`.
:param size: The size of image to return. Denotes the max width or max height
(whichever is larger).
:returns: The URI as a string.
"""
raise self._check_can_error("get_cover_art_uri")
def get_song_uri(self, song_id: str, scheme: str, stream: bool = False) -> str:
"""
Get a URI for a given song.
:param song_id: The ID of the song to get a URI for.
:param scheme: The URI scheme that should be returned. It is guaranteed that
``scheme`` will be one of the schemes returned by
:class:`supported_schemes`.
:param stream: Whether or not the URI returned should be a stream URI. This will
only be ``True`` if :class:`supports_streaming` returns ``True``.
:returns: The URI for the given song.
"""
# TODO (#189)
raise self._check_can_error("get_song_uri")
def get_song_details(self, song_id: str) -> Song:
"""
Get the details for a given song ID.
:param song_id: The ID of the song to get the details for.
:returns: The :class:`sublime.adapters.api_objects.Song`.
"""
raise self._check_can_error("get_song_details")
def scrobble_song(self, song: Song):
"""
Scrobble the given song.
:params song: The :class:`sublime.adapters.api_objects.Song` to scrobble.
"""
raise self._check_can_error("scrobble_song")
def get_artists(self) -> Sequence[Artist]:
"""
Get a list of all of the artists known to the adapter.
:returns: A list of all of the :class:`sublime.adapter.api_objects.Artist`
objects known to the adapter.
"""
raise self._check_can_error("get_artists")
def get_artist(self, artist_id: str) -> Artist:
"""
Get the details for the given artist ID.
:param artist_id: The ID of the artist to get the details for.
:returns: The :classs`sublime.adapters.api_objects.Artist`
"""
raise self._check_can_error("get_artist")
def get_ignored_articles(self) -> Set[str]:
"""
Get the list of articles to ignore when sorting artists by name.
:returns: A set of articles (i.e. The, A, El, La, Los) to ignore when sorting
artists.
"""
raise self._check_can_error("get_ignored_articles")
def get_albums(
self, query: AlbumSearchQuery, sort_direction: str = "ascending"
) -> Sequence[Album]:
"""
Get a list of all of the albums known to the adapter for the given query.
.. note::
This request is not paged. You should do any page management to get all of
the albums matching the query internally.
:param query: An :class:`AlbumSearchQuery` object representing the types of
albums to return.
:returns: A list of all of the :class:`sublime.adapter.api_objects.Album`
objects known to the adapter that match the query.
"""
raise self._check_can_error("get_albums")
def get_album(self, album_id: str) -> Album:
"""
Get the details for the given album ID.
:param album_id: The ID of the album to get the details for.
:returns: The :classs`sublime.adapters.api_objects.Album`
"""
raise self._check_can_error("get_album")
def get_directory(self, directory_id: str) -> Directory:
"""
Return a Directory object representing the song files and directories in the
given directory. This may not make sense for your adapter (for example, if
there's no actual underlying filesystem). In that case, make sure to set
:class:`can_get_directory` to ``False``.
:param directory_id: The directory to retrieve. If the special value ``"root"``
is given, the adapter should list all of the directories at the root of the
filesystem tree.
:returns: A list of the :class:`sublime.adapter.api_objects.Directory` and
:class:`sublime.adapter.api_objects.Song` objects in the given directory.
"""
raise self._check_can_error("get_directory")
def get_genres(self) -> Sequence[Genre]:
"""
Get a list of the genres known to the adapter.
:returns: A list of all of the :classs`sublime.adapter.api_objects.Genre`
objects known to the adapter.
"""
raise self._check_can_error("get_genres")
def get_play_queue(self) -> Optional[PlayQueue]:
"""
Returns the state of the play queue for this user. This could be used to restore
the play queue from the cloud.
:returns: The cloud-saved play queue as a
:class:`sublime.adapter.api_objects.PlayQueue` object.
"""
raise self._check_can_error("get_play_queue")
def save_play_queue(
self,
song_ids: Sequence[int],
current_song_index: int = None,
position: timedelta = None,
):
"""
Save the current play queue to the cloud.
:param song_ids: A list of the song IDs in the queue.
:param current_song_index: The index of the song that is currently being played.
:param position: The current position in the song.
"""
raise self._check_can_error("can_save_play_queue")
def search(self, query: str) -> SearchResult:
"""
Return search results fro the given query.
:param query: The query string.
:returns: A :class:`sublime.adapters.api_objects.SearchResult` object
representing the results of the search.
"""
raise self._check_can_error("can_save_play_queue")
@staticmethod
def _check_can_error(method_name: str) -> NotImplementedError:
return NotImplementedError(
f"Adapter.{method_name} called. "
"Did you forget to check that can_{method_name} is True?"
)
class CachingAdapter(Adapter):
"""
Defines an adapter that can be used as a cache for another adapter.
A caching adapter sits "in front" of a non-caching adapter and the UI will attempt
to retrieve the data from the caching adapter before retrieving it from the
non-caching adapter. (The exception is when the UI requests that the data come
directly from the ground truth adapter, in which case the cache will be bypassed.)
Caching adapters *must* be able to service requests instantly, or nearly instantly
(in most cases, this means that the data must come directly from the local
filesystem).
"""
@abc.abstractmethod
def __init__(self, config: dict, data_directory: Path, is_cache: bool = False):
"""
This function should be overridden by inheritors of :class:`CachingAdapter` and
should be used to do whatever setup is required for the adapter.
:param config: The adapter configuration. The keys of are the configuration
parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual value of
the configuration parameter.
:param data_directory: the directory where the adapter can store data. This
directory is guaranteed to exist.
:param is_cache: whether or not the adapter is being used as a cache.
"""
# Data Ingestion Methods
# ==================================================================================
class CachedDataKey(Enum):
ALBUM = "album"
ALBUMS = "albums"
ARTIST = "artist"
ARTISTS = "artists"
COVER_ART_FILE = "cover_art_file"
DIRECTORY = "directory"
GENRES = "genres"
IGNORED_ARTICLES = "ignored_articles"
PLAYLIST_DETAILS = "get_playlist_details"
PLAYLISTS = "get_playlists"
SEARCH_RESULTS = "search_results"
SONG = "song"
SONG_FILE = "song_file"
SONG_FILE_PERMANENT = "song_file_permanent"
@abc.abstractmethod
def ingest_new_data(self, data_key: CachedDataKey, param: Optional[str], data: Any):
"""
This function will be called after the fallback, ground-truth adapter returns
new data. This normally will happen if this adapter has a cache miss or if the
UI forces retrieval from the ground-truth adapter.
:param data_key: the type of data to be ingested.
:param param: a string that uniquely identify the data to be ingested. For
example, with playlist details, this will be the playlist ID. If that
playlist ID is requested again, the adapter should service that request, but
it should not service a request for a different playlist ID.
For the playlist list, this will be none since there are no parameters to
that request.
:param data: the data that was returned by the ground truth adapter.
"""
@abc.abstractmethod
def invalidate_data(self, data_key: CachedDataKey, param: Optional[str]):
"""
This function will be called if the adapter should invalidate some of its data.
This should not destroy the invalidated data. If invalid data is requested, a
``CacheMissError`` should be thrown, but the old data should be included in the
``partial_data`` field of the error.
:param data_key: the type of data to be invalidated.
:param params: the parameters that uniquely identify the data to be invalidated.
For example, with playlist details, this will be the playlist ID.
For the playlist list, this will be none since there are no parameters to
that request.
"""
@abc.abstractmethod
def delete_data(self, data_key: CachedDataKey, param: Optional[str]):
"""
This function will be called if the adapter should delete some of its data.
This should destroy the data. If the deleted data is requested, a
``CacheMissError`` should be thrown with no data in the ``partial_data`` field.
:param data_key: the type of data to be deleted.
:param params: the parameters that uniquely identify the data to be invalidated.
For example, with playlist details, this will be the playlist ID.
For the playlist list, this will be none since there are no parameters to
that request.
"""
# Cache-Specific Methods
# ==================================================================================
@abc.abstractmethod
def get_cached_statuses(
self, song_ids: Sequence[str]
) -> Dict[str, SongCacheStatus]:
"""
Returns the cache statuses for the given list of songs. See the
:class:`SongCacheStatus` documentation for more details about what each status
means.
:params songs: The songs to get the cache status for.
:returns: A dictionary of song ID to :class:`SongCacheStatus` objects for each
of the songs.
"""

View File

@@ -0,0 +1,219 @@
"""
Defines the objects that are returned by adapter methods.
"""
import abc
import logging
from datetime import datetime, timedelta
from functools import lru_cache, partial
from typing import (
Any,
Callable,
cast,
Dict,
Iterable,
List,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
)
from fuzzywuzzy import fuzz
class Genre(abc.ABC):
name: str
song_count: Optional[int]
album_count: Optional[int]
class Album(abc.ABC):
"""
The ``id`` field is optional, because there are some situations where an adapter
(such as Subsonic) sends an album name, but not an album ID.
"""
name: str
id: Optional[str]
artist: Optional["Artist"]
cover_art: Optional[str]
created: Optional[datetime]
duration: Optional[timedelta]
genre: Optional[Genre]
play_count: Optional[int]
song_count: Optional[int]
songs: Optional[Sequence["Song"]]
starred: Optional[datetime]
year: Optional[int]
class Artist(abc.ABC):
"""
The ``id`` field is optional, because there are some situations where an adapter
(such as Subsonic) sends an artist name, but not an artist ID. This especially
happens when there are multiple artists.
"""
name: str
id: Optional[str]
album_count: Optional[int]
artist_image_url: Optional[str]
starred: Optional[datetime]
albums: Optional[Sequence[Album]]
similar_artists: Optional[Sequence["Artist"]] = None
biography: Optional[str] = None
music_brainz_id: Optional[str] = None
last_fm_url: Optional[str] = None
class Directory(abc.ABC):
"""
The special directory with ``name`` and ``id`` should be used to indicate the
top-level directory.
"""
id: str
name: Optional[str]
parent_id: Optional[str]
children: Sequence[Union["Directory", "Song"]]
class Song(abc.ABC):
id: str
title: str
path: Optional[str]
parent_id: Optional[str]
duration: Optional[timedelta]
album: Optional[Album]
artist: Optional[Artist]
genre: Optional[Genre]
track: Optional[int]
disc_number: Optional[int]
year: Optional[int]
cover_art: Optional[str]
user_rating: Optional[int]
starred: Optional[datetime]
class Playlist(abc.ABC):
id: str
name: str
song_count: Optional[int]
duration: Optional[timedelta]
songs: Sequence[Song]
created: Optional[datetime]
changed: Optional[datetime]
comment: Optional[str]
owner: Optional[str]
public: Optional[bool]
cover_art: Optional[str]
class PlayQueue(abc.ABC):
songs: Sequence[Song]
position: timedelta
username: Optional[str]
changed: Optional[datetime]
changed_by: Optional[str]
value: Optional[str]
current_index: Optional[int]
@lru_cache(maxsize=8192)
def similarity_ratio(query: str, string: str) -> int:
"""
Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and
the given ``string``.
This ends up being called quite a lot, so the result is cached in an LRU
cache using :class:`functools.lru_cache`.
:param query: the query string
:param string: the string to compare to the query string
"""
return fuzz.partial_ratio(query, string)
class SearchResult:
"""
An object representing the aggregate results of a search which can include
both server and local results.
"""
def __init__(self, query: str = None):
self.query = query
self._artists: Dict[str, Artist] = {}
self._albums: Dict[str, Album] = {}
self._songs: Dict[str, Song] = {}
self._playlists: Dict[str, Playlist] = {}
def add_results(self, result_type: str, results: Iterable):
"""Adds the ``results`` to the ``_result_type`` set."""
if results is None:
return
member = f"_{result_type}"
cast(Dict[str, Any], getattr(self, member)).update({r.id: r for r in results})
def update(self, other: "SearchResult"):
assert self.query == other.query
self._artists.update(other._artists)
self._albums.update(other._albums)
self._songs.update(other._songs)
self._playlists.update(other._playlists)
_S = TypeVar("_S")
def _to_result(
self, it: Dict[str, _S], transform: Callable[[_S], Tuple[Optional[str], ...]],
) -> List[_S]:
assert self.query
all_results = sorted(
(
(
max(
partial(similarity_ratio, self.query.lower())(t.lower())
for t in transform(x)
if t is not None
),
x,
)
for x in it.values()
),
key=lambda rx: rx[0],
reverse=True,
)
result: List[SearchResult._S] = []
for ratio, x in all_results:
if ratio >= 60 and len(result) < 20:
result.append(x)
else:
# No use going on, all the rest are less.
break
logging.debug(similarity_ratio.cache_info())
return result
@property
def artists(self) -> List[Artist]:
return self._to_result(self._artists, lambda a: (a.name,))
@property
def albums(self) -> List[Album]:
return self._to_result(
self._albums, lambda a: (a.name, a.artist.name if a.artist else None)
)
@property
def songs(self) -> List[Song]:
return self._to_result(
self._songs, lambda s: (s.title, s.artist.name if s.artist else None)
)
@property
def playlists(self) -> List[Playlist]:
return self._to_result(self._playlists, lambda p: (p.name,))

View File

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

View File

@@ -0,0 +1,852 @@
import hashlib
import logging
import shutil
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, cast, Dict, Optional, Sequence, Set, Tuple, Union
from peewee import fn, prefetch
from sublime.adapters import api_objects as API
from . import models
from .. import (
AlbumSearchQuery,
CacheMissError,
CachingAdapter,
ConfigParamDescriptor,
SongCacheStatus,
)
KEYS = CachingAdapter.CachedDataKey
class FilesystemAdapter(CachingAdapter):
"""
Defines an adapter which retrieves its data from the local filesystem.
"""
# Configuration and Initialization Properties
# ==================================================================================
@staticmethod
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
return {
# TODO (#188): directory path, whether or not to scan tags
}
@staticmethod
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
return {
# TODO (#188): verify that the path exists
}
def __init__(
self, config: dict, data_directory: Path, is_cache: bool = False,
):
self.data_directory = data_directory
self.cover_art_dir = self.data_directory.joinpath("cover_art")
self.music_dir = self.data_directory.joinpath("music")
self.cover_art_dir.mkdir(parents=True, exist_ok=True)
self.music_dir.mkdir(parents=True, exist_ok=True)
self.is_cache = is_cache
self.db_write_lock: threading.Lock = threading.Lock()
database_filename = data_directory.joinpath("cache.db")
models.database.init(database_filename)
models.database.connect()
with self.db_write_lock, models.database.atomic():
models.database.create_tables(models.ALL_TABLES)
self._migrate_db()
def initial_sync(self):
# TODO (#188) this is where scanning the fs should potentially happen?
pass
def shutdown(self):
logging.info("Shutdown complete")
# Database Migration
# ==================================================================================
def _migrate_db(self):
pass
# Usage and Availability Properties
# ==================================================================================
can_be_cached = False # Can't be cached (there's no need).
is_networked = False # Doesn't access the network.
can_service_requests = True # Can always be used to service requests.
# TODO (#200) make these dependent on cache state. Need to do this kinda efficiently
can_get_cover_art_uri = True
can_get_song_uri = True
can_get_song_details = True
can_get_artist = True
can_get_albums = True
can_get_album = True
can_get_ignored_articles = True
can_get_directory = True
can_search = True
def _can_get_key(self, cache_key: CachingAdapter.CachedDataKey) -> bool:
if not self.is_cache:
return True
# As long as there's something in the cache (even if it's not valid) it may be
# returned in a cache miss error.
query = models.CacheInfo.select().where(models.CacheInfo.cache_key == cache_key)
return query.count() > 0
@property
def can_get_playlists(self) -> bool:
return self._can_get_key(KEYS.PLAYLISTS)
@property
def can_get_playlist_details(self) -> bool:
return self._can_get_key(KEYS.PLAYLIST_DETAILS)
@property
def can_get_artists(self) -> bool:
return self._can_get_key(KEYS.ARTISTS)
@property
def can_get_genres(self) -> bool:
return self._can_get_key(KEYS.GENRES)
supported_schemes = ("file",)
# TODO (#203)
supported_artist_query_types = {
AlbumSearchQuery.Type.RANDOM,
AlbumSearchQuery.Type.NEWEST,
AlbumSearchQuery.Type.FREQUENT,
AlbumSearchQuery.Type.RECENT,
AlbumSearchQuery.Type.STARRED,
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST,
AlbumSearchQuery.Type.YEAR_RANGE,
AlbumSearchQuery.Type.GENRE,
}
# Data Helper Methods
# ==================================================================================
def _get_list(
self,
model: Any,
cache_key: CachingAdapter.CachedDataKey,
ignore_cache_miss: bool = False,
where_clauses: Tuple[Any, ...] = None,
) -> Sequence:
result = model.select()
if where_clauses is not None:
result = result.where(*where_clauses)
if self.is_cache and not ignore_cache_miss:
# Determine if the adapter has ingested data for this key before, and if
# not, cache miss.
if not models.CacheInfo.get_or_none(
models.CacheInfo.valid == True, # noqa: 712
models.CacheInfo.cache_key == cache_key,
):
raise CacheMissError(partial_data=result)
return result
def _get_object_details(
self, model: Any, id: str, cache_key: CachingAdapter.CachedDataKey
) -> Any:
obj = model.get_or_none(model.id == id)
# Handle the case that this is the ground truth adapter.
if not self.is_cache:
if not obj:
raise Exception(f"{model} with id={id} does not exist")
return obj
# If we haven't ingested data for this item before, or it's been invalidated,
# raise a CacheMissError with the partial data.
cache_info = models.CacheInfo.get_or_none(
models.CacheInfo.cache_key == cache_key,
models.CacheInfo.parameter == id,
models.CacheInfo.valid == True, # noqa: 712
)
if not cache_info:
raise CacheMissError(partial_data=obj)
return obj
def _compute_song_filename(self, cache_info: models.CacheInfo) -> Path:
if path_str := cache_info.path:
# Make sure that the path is somewhere in the cache directory and a
# malicious server (or MITM attacker) isn't trying to override files in
# other parts of the system.
path = self.music_dir.joinpath(path_str)
if self.music_dir in path.parents:
return path
# Fall back to using the song file hash as the filename. This shouldn't happen
# with good servers, but just to be safe.
return self.music_dir.joinpath(cache_info.file_hash)
# Data Retrieval Methods
# ==================================================================================
def get_cached_statuses(
self, song_ids: Sequence[str]
) -> Dict[str, SongCacheStatus]:
def compute_song_cache_status(song: models.Song) -> SongCacheStatus:
file = song.file
if self._compute_song_filename(file).exists():
if file.valid:
if file.cache_permanently:
return SongCacheStatus.PERMANENTLY_CACHED
return SongCacheStatus.CACHED
# The file is on disk, but marked as stale.
return SongCacheStatus.CACHED_STALE
return SongCacheStatus.NOT_CACHED
try:
file_models = models.CacheInfo.select().where(
models.CacheInfo.cache_key == KEYS.SONG_FILE
)
song_models = models.Song.select().where(models.Song.id.in_(song_ids))
return {
s.id: compute_song_cache_status(s)
for s in prefetch(song_models, file_models)
}
except Exception:
pass
return {song_id: SongCacheStatus.NOT_CACHED for song_id in song_ids}
_playlists = None
def get_playlists(self, ignore_cache_miss: bool = False) -> Sequence[API.Playlist]:
if self._playlists is not None:
return self._playlists
self._playlists = self._get_list(
models.Playlist,
CachingAdapter.CachedDataKey.PLAYLISTS,
ignore_cache_miss=ignore_cache_miss,
)
return self._playlists
def get_playlist_details(self, playlist_id: str) -> API.Playlist:
return self._get_object_details(
models.Playlist, playlist_id, CachingAdapter.CachedDataKey.PLAYLIST_DETAILS
)
def get_cover_art_uri(self, cover_art_id: str, scheme: str, size: int) -> str:
cover_art = models.CacheInfo.get_or_none(
models.CacheInfo.cache_key == CachingAdapter.CachedDataKey.COVER_ART_FILE,
models.CacheInfo.parameter == cover_art_id,
)
if cover_art:
filename = self.cover_art_dir.joinpath(str(cover_art.file_hash))
if cover_art.valid and filename.exists():
return str(filename)
raise CacheMissError(partial_data=str(filename))
raise CacheMissError()
def get_song_uri(self, song_id: str, scheme: str, stream: bool = False) -> str:
song = models.Song.get_or_none(models.Song.id == song_id)
if not song:
if self.is_cache:
raise CacheMissError()
else:
raise Exception(f"Song {song_id} does not exist.")
try:
if (song_file := song.file) and (
filename := self._compute_song_filename(song_file)
):
if song_file.valid and filename.exists():
return str(filename)
raise CacheMissError(partial_data=str(filename))
except models.CacheInfo.DoesNotExist:
pass
raise CacheMissError()
def get_song_details(self, song_id: str) -> models.Song:
return self._get_object_details(
models.Song, song_id, CachingAdapter.CachedDataKey.SONG,
)
def get_artists(self, ignore_cache_miss: bool = False) -> Sequence[API.Artist]:
return self._get_list(
models.Artist,
CachingAdapter.CachedDataKey.ARTISTS,
ignore_cache_miss=ignore_cache_miss,
where_clauses=(~(models.Artist.id.startswith("invalid:")),),
)
def get_artist(self, artist_id: str) -> API.Artist:
return self._get_object_details(
models.Artist, artist_id, CachingAdapter.CachedDataKey.ARTIST
)
def get_albums(
self, query: AlbumSearchQuery, sort_direction: str = "ascending"
) -> Sequence[API.Album]:
strhash = query.strhash()
query_result = models.AlbumQueryResult.get_or_none(
models.AlbumQueryResult.query_hash == strhash
)
# If we've cached the query result, then just return it. If it's stale, then
# return the old value as a cache miss error.
if query_result and (
cache_info := models.CacheInfo.get_or_none(
models.CacheInfo.cache_key == CachingAdapter.CachedDataKey.ALBUMS,
models.CacheInfo.parameter == strhash,
)
):
if cache_info.valid:
return query_result.albums
else:
raise CacheMissError(partial_data=query_result.albums)
# If we haven't ever cached the query result, try to construct one, and return
# it as a CacheMissError result.
sql_query = models.Album.select().where(
~(models.Album.id.startswith("invalid:"))
)
Type = AlbumSearchQuery.Type
if query.type == Type.GENRE:
assert query.genre
genre_name = genre.name if (genre := query.genre) else None
sql_query = {
Type.RANDOM: sql_query.order_by(fn.Random()),
Type.NEWEST: sql_query.order_by(models.Album.created.desc()),
Type.FREQUENT: sql_query.order_by(models.Album.play_count.desc()),
Type.STARRED: sql_query.where(models.Album.starred.is_null(False)).order_by(
models.Album.name
),
Type.ALPHABETICAL_BY_NAME: sql_query.order_by(models.Album.name),
Type.ALPHABETICAL_BY_ARTIST: sql_query.order_by(models.Album.artist.name),
Type.YEAR_RANGE: sql_query.where(
models.Album.year.between(*query.year_range)
).order_by(models.Album.year, models.Album.name),
Type.GENRE: sql_query.where(models.Album.genre == genre_name).order_by(
models.Album.name
),
}.get(query.type)
raise CacheMissError(partial_data=sql_query)
def get_all_albums(self) -> Sequence[API.Album]:
return self._get_list(
models.Album,
CachingAdapter.CachedDataKey.ALBUMS,
ignore_cache_miss=True,
where_clauses=(~(models.Album.id.startswith("invalid:")),),
)
def get_album(self, album_id: str) -> API.Album:
return self._get_object_details(
models.Album, album_id, CachingAdapter.CachedDataKey.ALBUM
)
def get_ignored_articles(self) -> Set[str]:
return set(
map(
lambda i: i.name,
self._get_list(
models.IgnoredArticle, CachingAdapter.CachedDataKey.IGNORED_ARTICLES
),
)
)
def get_directory(self, directory_id: str) -> models.Directory:
return self._get_object_details(
models.Directory, directory_id, CachingAdapter.CachedDataKey.DIRECTORY
)
def get_genres(self) -> Sequence[API.Genre]:
return self._get_list(models.Genre, CachingAdapter.CachedDataKey.GENRES)
def search(self, query: str) -> API.SearchResult:
search_result = API.SearchResult(query)
search_result.add_results("albums", self.get_all_albums())
search_result.add_results("artists", self.get_artists(ignore_cache_miss=True))
search_result.add_results(
"songs",
self._get_list(
models.Song, CachingAdapter.CachedDataKey.SONG, ignore_cache_miss=True
),
)
search_result.add_results(
"playlists", self.get_playlists(ignore_cache_miss=True)
)
return search_result
# Data Ingestion Methods
# ==================================================================================
def _strhash(self, string: str) -> str:
return hashlib.sha1(bytes(string, "utf8")).hexdigest()
def ingest_new_data(
self, data_key: CachingAdapter.CachedDataKey, param: Optional[str], data: Any,
):
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_ingest_new_data(data_key, param, data)
def invalidate_data(
self, function: 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)
def delete_data(self, function: 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)
def _do_ingest_new_data(
self,
data_key: CachingAdapter.CachedDataKey,
param: Optional[str],
data: Any,
partial: bool = False,
) -> Any:
# 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 setattrs(obj: Any, data: Dict[str, Any]):
for k, v in data.items():
if v:
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,
}
if not partial:
directory_data["directory_children"] = []
directory_data["song_children"] = []
for c in api_directory.children:
if hasattr(c, "children"): # directory
directory_data["directory_children"].append(
self._do_ingest_new_data(
KEYS.DIRECTORY, c.id, c, partial=True
)
)
else:
directory_data["song_children"].append(
self._do_ingest_new_data(KEYS.SONG, c.id, c)
)
directory, created = models.Directory.get_or_create(
id=api_directory.id, defaults=directory_data
)
if not created:
setattrs(directory, directory_data)
directory.save()
return 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),
}
genre, created = models.Genre.get_or_create(
name=api_genre.name, defaults=genre_data
)
if not created:
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)
)
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)
elif data_key == KEYS.IGNORED_ARTICLES:
models.IgnoredArticle.insert_many(
map(lambda s: {"name": s}, data)
).on_conflict_replace().execute()
models.IgnoredArticle.delete().where(
models.IgnoredArticle.name.not_in(data)
).execute()
elif data_key == KEYS.PLAYLIST_DETAILS:
return_val = ingest_playlist(data)
elif data_key == KEYS.PLAYLISTS:
self._playlists = None
for p in data:
ingest_playlist(p)
models.Playlist.delete().where(
models.Playlist.id.not_in([p.id for p in data])
).execute()
elif data_key == KEYS.SEARCH_RESULTS:
data = cast(API.SearchResult, data)
for a in data._artists.values():
ingest_artist_data(a)
for a in data._albums.values():
ingest_album_data(a)
for s in data._songs.values():
ingest_song_data(s)
for p in data._playlists.values():
ingest_playlist(p)
elif data_key == KEYS.SONG:
return_val = ingest_song_data(data)
elif data_key == KEYS.SONG_FILE:
cache_info_extra["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()
# Special handling for Song
if data_key == KEYS.SONG_FILE and data:
path, buffer_filename = data
if path:
cache_info.path = path
if buffer_filename:
cache_info.file_hash = compute_file_hash(buffer_filename)
# Copy the actual song file from the download buffer dir to the cache
# dir.
filename = self._compute_song_filename(cache_info)
filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(str(buffer_filename), str(filename))
cache_info.save()
return return_val if return_val is not None else cache_info
def _do_invalidate_data(
self, data_key: CachingAdapter.CachedDataKey, param: Optional[str],
):
logging.debug(f"_do_invalidate_data param={param} data_key={data_key}")
models.CacheInfo.update({"valid": False}).where(
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:
# Invalidate the corresponding cover art.
if artist := models.Artist.get_or_none(models.Artist.id == param):
self._do_invalidate_data(cover_art_cache_key, 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:
# 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)
elif data_key == CachingAdapter.CachedDataKey.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
)
def _do_delete_data(
self, data_key: CachingAdapter.CachedDataKey, param: Optional[str]
):
logging.debug(f"_do_delete_data param={param} data_key={data_key}")
cache_info = models.CacheInfo.get_or_none(
models.CacheInfo.cache_key == data_key, models.CacheInfo.parameter == param,
)
if data_key == CachingAdapter.CachedDataKey.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:
# 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
)
playlist.delete_instance()
elif data_key == CachingAdapter.CachedDataKey.SONG_FILE:
if cache_info:
self._compute_song_filename(cache_info).unlink(missing_ok=True)
cache_info.delete_instance()

View File

@@ -0,0 +1,244 @@
from typing import List, Optional, Union
from peewee import (
AutoField,
BooleanField,
ForeignKeyField,
IntegerField,
Model,
prefetch,
Query,
SqliteDatabase,
TextField,
)
from .sqlite_extensions import (
CacheConstantsField,
DurationField,
SortedManyToManyField,
TzDateTimeField,
)
database = SqliteDatabase(None)
# Models
# =============================================================================
class BaseModel(Model):
class Meta:
database = database
class CacheInfo(BaseModel):
id = AutoField()
valid = BooleanField(default=False)
cache_key = CacheConstantsField()
parameter = TextField(null=True, default="")
# TODO (#2) actually use this for cache expiry.
last_ingestion_time = TzDateTimeField(null=False)
class Meta:
indexes = ((("cache_key", "parameter"), True),)
# Used for cached files.
file_id = TextField(null=True)
file_hash = TextField(null=True)
path = TextField(null=True)
cache_permanently = BooleanField(null=True)
class Genre(BaseModel):
name = TextField(unique=True, primary_key=True)
song_count = IntegerField(null=True)
album_count = IntegerField(null=True)
class Artist(BaseModel):
id = TextField(unique=True, primary_key=True)
name = TextField(null=True)
album_count = IntegerField(null=True)
starred = TzDateTimeField(null=True)
biography = TextField(null=True)
music_brainz_id = TextField(null=True)
last_fm_url = TextField(null=True)
_artist_image_url = ForeignKeyField(CacheInfo, null=True)
@property
def artist_image_url(self) -> Optional[str]:
try:
return self._artist_image_url.file_id
except Exception:
return None
@property
def similar_artists(self) -> Query:
return SimilarArtist.select().where(SimilarArtist.artist == self.id)
class SimilarArtist(BaseModel):
artist = ForeignKeyField(Artist)
similar_artist = ForeignKeyField(Artist)
order = IntegerField()
class Meta:
# The whole thing is unique.
indexes = ((("artist", "similar_artist", "order"), True),)
class Album(BaseModel):
id = TextField(unique=True, primary_key=True)
created = TzDateTimeField(null=True)
duration = DurationField(null=True)
name = TextField(null=True)
play_count = IntegerField(null=True)
song_count = IntegerField(null=True)
starred = TzDateTimeField(null=True)
year = IntegerField(null=True)
artist = ForeignKeyField(Artist, null=True, backref="albums")
genre = ForeignKeyField(Genre, null=True, backref="albums")
_cover_art = ForeignKeyField(CacheInfo, null=True)
@property
def cover_art(self) -> Optional[str]:
try:
return self._cover_art.file_id
except Exception:
return None
class AlbumQueryResult(BaseModel):
query_hash = TextField(primary_key=True)
albums = SortedManyToManyField(Album)
class IgnoredArticle(BaseModel):
name = TextField(unique=True, primary_key=True)
class Directory(BaseModel):
id = TextField(unique=True, primary_key=True)
name = TextField(null=True)
parent_id = TextField(null=True)
_children: Optional[List[Union["Directory", "Song"]]] = None
@property
def children(self) -> List[Union["Directory", "Song"]]:
if not self._children:
self._children = list(
Directory.select().where(Directory.parent_id == self.id)
) + list(Song.select().where(Song.parent_id == self.id))
return self._children
@children.setter
def children(self, value: List[Union["Directory", "Song"]]):
self._children = value
class Song(BaseModel):
id = TextField(unique=True, primary_key=True)
title = TextField()
duration = DurationField(null=True)
parent_id = TextField(null=True)
album = ForeignKeyField(Album, null=True, backref="songs")
artist = ForeignKeyField(Artist, null=True)
genre = ForeignKeyField(Genre, null=True, backref="songs")
# figure out how to deal with different transcodings, etc.
file = ForeignKeyField(CacheInfo, null=True)
@property
def path(self) -> Optional[str]:
try:
return self.file.path
except Exception:
return None
_cover_art = ForeignKeyField(CacheInfo, null=True)
@property
def cover_art(self) -> Optional[str]:
try:
return self._cover_art.file_id
except Exception:
return None
track = IntegerField(null=True)
disc_number = IntegerField(null=True)
year = IntegerField(null=True)
user_rating = IntegerField(null=True)
starred = TzDateTimeField(null=True)
class Playlist(BaseModel):
id = TextField(unique=True, primary_key=True)
name = TextField()
comment = TextField(null=True)
owner = TextField(null=True)
song_count = IntegerField(null=True)
duration = DurationField(null=True)
created = TzDateTimeField(null=True)
changed = TzDateTimeField(null=True)
public = BooleanField(null=True)
_songs = SortedManyToManyField(Song, backref="playlists")
@property
def songs(self) -> List[Song]:
albums = Album.select()
artists = Album.select()
return prefetch(self._songs, albums, artists)
_cover_art = ForeignKeyField(CacheInfo, null=True)
@property
def cover_art(self) -> Optional[str]:
try:
return self._cover_art.file_id
except Exception:
return None
class Version(BaseModel):
id = IntegerField(unique=True, primary_key=True)
major = IntegerField()
minor = IntegerField()
patch = IntegerField()
@staticmethod
def is_less_than(semver: str) -> bool:
major, minor, patch = map(int, semver.split("."))
version, created = Version.get_or_create(
id=0, defaults={"major": major, "minor": minor, "patch": patch}
)
if created:
# There was no version before, definitely out-of-date
return True
return version.major < major or version.minor < minor or version.patch < patch
@staticmethod
def update_version(semver: str):
major, minor, patch = map(int, semver.split("."))
Version.update(major=major, minor=minor, patch=patch)
ALL_TABLES = (
Album,
AlbumQueryResult,
AlbumQueryResult.albums.get_through_model(),
Artist,
CacheInfo,
Directory,
Genre,
IgnoredArticle,
Playlist,
Playlist._songs.get_through_model(),
SimilarArtist,
Song,
Version,
)

View File

@@ -0,0 +1,119 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Sequence
from peewee import (
DoubleField,
ensure_tuple,
ForeignKeyField,
IntegerField,
ManyToManyField,
ManyToManyFieldAccessor,
ManyToManyQuery,
Model,
SelectQuery,
TextField,
)
from sublime.adapters.adapter_base import CachingAdapter
# Custom Fields
# =============================================================================
class CacheConstantsField(TextField):
def db_value(self, value: CachingAdapter.CachedDataKey) -> str:
return value.value
def python_value(self, value: str) -> CachingAdapter.CachedDataKey:
return CachingAdapter.CachedDataKey(value)
class DurationField(DoubleField):
def db_value(self, value: timedelta) -> Optional[float]:
return value.total_seconds() if value else None
def python_value(self, value: Optional[float]) -> Optional[timedelta]:
return timedelta(seconds=value) if value else None
class TzDateTimeField(TextField):
def db_value(self, value: Optional[datetime]) -> Optional[str]:
return value.isoformat() if value else None
def python_value(self, value: Optional[str]) -> Optional[datetime]:
return datetime.fromisoformat(value) if value else None
# Sorted M-N Association Field
# =============================================================================
class SortedManyToManyQuery(ManyToManyQuery):
def add(self, value: Sequence[Any], clear_existing: bool = False):
if clear_existing:
self.clear()
accessor = self._accessor
src_id = getattr(self._instance, self._src_attr)
assert not isinstance(value, SelectQuery)
value = ensure_tuple(value)
if not value:
return
inserts = [
{
accessor.src_fk.name: src_id,
accessor.dest_fk.name: rel_id,
"position": i,
}
for i, rel_id in enumerate(self._id_list(value))
]
accessor.through_model.insert_many(inserts).execute()
class SortedManyToManyFieldAccessor(ManyToManyFieldAccessor):
def __get__(
self, instance: Model, instance_type: Any = None, force_query: bool = False,
):
if instance is not None:
if not force_query and self.src_fk.backref != "+":
backref = getattr(instance, self.src_fk.backref)
if isinstance(backref, list):
return [getattr(obj, self.dest_fk.name) for obj in backref]
src_id = getattr(instance, self.src_fk.rel_field.name)
return (
SortedManyToManyQuery(instance, self, self.rel_model)
.join(self.through_model)
.join(self.model)
.where(self.src_fk == src_id)
.order_by(self.through_model.position)
)
return self.field
def __set__(self, instance: Model, value: Sequence[Any]):
query = self.__get__(instance, force_query=True)
query.add(value, clear_existing=True)
class SortedManyToManyField(ManyToManyField):
accessor_class = SortedManyToManyFieldAccessor
def _create_through_model(self) -> type:
lhs, rhs = self.get_models()
tables = [model._meta.table_name for model in (lhs, rhs)]
class Meta:
database = self.model._meta.database
schema = self.model._meta.schema
table_name = "{}_{}_through".format(*tables)
indexes = (((lhs._meta.name, rhs._meta.name, "position"), True),)
params = {"on_delete": self._on_delete, "on_update": self._on_update}
attrs = {
lhs._meta.name: ForeignKeyField(lhs, **params),
rhs._meta.name: ForeignKeyField(rhs, **params),
"position": IntegerField(),
"Meta": Meta,
}
klass_name = "{}{}Through".format(lhs.__name__, rhs.__name__)
return type(klass_name, (Model,), attrs)

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

1211
sublime/adapters/manager.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,543 @@
import json
import logging
import math
import multiprocessing
import os
import pickle
import random
from datetime import datetime, timedelta
from pathlib import Path
from time import sleep
from typing import (
Any,
cast,
Dict,
Iterable,
List,
Optional,
Sequence,
Set,
Tuple,
Union,
)
from urllib.parse import urlencode, urlparse
import requests
from .api_objects import Directory, Response
from .. import Adapter, AlbumSearchQuery, api_objects as API, ConfigParamDescriptor
try:
import gi
gi.require_version("NM", "1.0")
from gi.repository import NM
networkmanager_imported = True
except Exception:
# I really don't care what kind of exception it is, all that matters is the
# import failed for some reason.
logging.warning(
"Unable to import NM from GLib. Detection of SSID will be disabled."
)
networkmanager_imported = False
REQUEST_DELAY: Optional[Tuple[float, float]] = None
if delay_str := os.environ.get("REQUEST_DELAY"):
if "," in delay_str:
high, low = map(float, delay_str.split(","))
REQUEST_DELAY = (high, low)
else:
REQUEST_DELAY = (float(delay_str), float(delay_str))
class SubsonicAdapter(Adapter):
"""
Defines an adapter which retrieves its data from a Subsonic server
"""
# Configuration and Initialization Properties
# ==================================================================================
@staticmethod
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
# TODO (#197) some way to test the connection to the server and a way to open
# the server URL in a browser
configs = {
"server_address": ConfigParamDescriptor(str, "Server address"),
"disable_cert_verify": ConfigParamDescriptor("password", "Password", False),
"username": ConfigParamDescriptor(str, "Username"),
"password": ConfigParamDescriptor("password", "Password"),
}
if networkmanager_imported:
configs.update(
{
"local_network_ssid": ConfigParamDescriptor(
str, "Local Network SSID"
),
"local_network_address": ConfigParamDescriptor(
str, "Local Network Address"
),
}
)
return configs
@staticmethod
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
errors: Dict[str, Optional[str]] = {}
# TODO (#197): verify the URL and ping it.
# Maybe have a special key like __ping_future__ or something along those lines
# to add a function that allows the UI to check whether or not connecting to the
# server will work?
return errors
def __init__(self, config: dict, data_directory: Path):
self.data_directory = data_directory
self.ignored_articles_cache_file = self.data_directory.joinpath(
"ignored_articles.pickle"
)
self.hostname = config["server_address"]
if ssid := config.get("local_network_ssid") and networkmanager_imported:
networkmanager_client = NM.Client.new()
# Only look at the active WiFi connections.
for ac in networkmanager_client.get_active_connections():
if ac.get_connection_type() != "802-11-wireless":
continue
devs = ac.get_devices()
if len(devs) != 1:
continue
if devs[0].get_device_type() != NM.DeviceType.WIFI:
continue
# If connected to the Local Network SSID, then change the hostname to
# the Local Network Address.
if ssid == ac.get_id():
self.hostname = config["local_network_address"]
break
self.username = config["username"]
self.password = config["password"]
self.disable_cert_verify = config.get("disable_cert_verify")
self.is_shutting_down = False
self.ping_process = multiprocessing.Process(target=self._check_ping_thread)
self.ping_process.start()
# TODO (#191): support XML?
def initial_sync(self):
# Wait for the ping to happen.
tries = 5
while not self._server_available.value and tries < 5:
self._set_ping_status()
tries += 1
def shutdown(self):
self.ping_process.terminate()
# Availability Properties
# ==================================================================================
_server_available = multiprocessing.Value("b", False)
def _check_ping_thread(self):
# TODO (#198): also use other requests in place of ping if they come in. If the
# time since the last successful request is high, then do another ping.
# TODO (#198): also use NM to detect when the connection changes and update
# accordingly.
while True:
self._set_ping_status()
sleep(15)
def _set_ping_status(self):
try:
# Try to ping the server with a timeout of 2 seconds.
self._get_json(self._make_url("ping"), timeout=2)
self._server_available.value = True
except Exception:
logging.exception(f"Could not connect to {self.hostname}")
self._server_available.value = False
@property
def can_service_requests(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_directory = True
can_get_genres = True
can_get_play_queue = True
can_save_play_queue = True
can_search = True
_schemes = None
@property
def supported_schemes(self) -> Iterable[str]:
if not self._schemes:
self._schemes = (urlparse(self.hostname)[0],)
return self._schemes
# TODO (#203) make this way smarter
supported_artist_query_types = {
AlbumSearchQuery.Type.RANDOM,
AlbumSearchQuery.Type.NEWEST,
AlbumSearchQuery.Type.FREQUENT,
AlbumSearchQuery.Type.RECENT,
AlbumSearchQuery.Type.STARRED,
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST,
AlbumSearchQuery.Type.YEAR_RANGE,
AlbumSearchQuery.Type.GENRE,
}
# Helper mothods for making requests
# ==================================================================================
def _get_params(self) -> Dict[str, str]:
"""
Gets the parameters that are needed for all requests to the Subsonic API. See
Subsonic API Introduction for details.
"""
return {
"u": self.username,
"p": self.password,
"c": "Sublime Music",
"f": "json",
"v": "1.15.0",
}
def _make_url(self, endpoint: str) -> str:
return f"{self.hostname}/rest/{endpoint}.view"
# TODO (#196) figure out some way of rate limiting requests. They often come in too
# fast.
def _get(
self,
url: str,
timeout: Union[float, Tuple[float, float], None] = None,
# TODO (#122): retry count
**params,
) -> Any:
params = {**self._get_params(), **params}
logging.info(f"[START] get: {url}")
if REQUEST_DELAY:
delay = random.uniform(*REQUEST_DELAY)
logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds")
sleep(delay)
if timeout:
if type(timeout) == tuple:
if cast(Tuple[float, float], timeout)[0] > delay:
raise TimeoutError("DUMMY TIMEOUT ERROR")
else:
if cast(float, timeout) > delay:
raise TimeoutError("DUMMY TIMEOUT ERROR")
# Deal with datetime parameters (convert to milliseconds since 1970)
for k, v in params.items():
if isinstance(v, datetime):
params[k] = int(v.timestamp() * 1000)
if self._is_mock:
logging.info("Using mock data")
return self._get_mock_data()
result = requests.get(
url, params=params, verify=not self.disable_cert_verify, timeout=timeout
)
# TODO (#122): make better
if result.status_code != 200:
raise Exception(f"[FAIL] get: {url} status={result.status_code}")
logging.info(f"[FINISH] get: {url}")
return result
def _get_json(
self,
url: str,
timeout: Union[float, Tuple[float, float], None] = None,
**params: Union[None, str, datetime, int, Sequence[int], Sequence[str]],
) -> Response:
"""
Make a get request to a *Sonic REST API. Handle all types of errors including
*Sonic ``<error>`` responses.
:returns: a dictionary of the subsonic response.
:raises Exception: needs some work
"""
result = self._get(url, timeout=timeout, **params)
subsonic_response = result.json().get("subsonic-response")
# TODO (#122): make better
if not subsonic_response:
raise Exception(f"[FAIL] get: invalid JSON from {url}")
if subsonic_response["status"] == "failed":
code, message = (
subsonic_response["error"].get("code"),
subsonic_response["error"].get("message"),
)
raise Exception(f"Subsonic API Error #{code}: {message}")
logging.debug(f"Response from {url}: {subsonic_response}")
return Response.from_dict(subsonic_response)
# Helper Methods for Testing
_get_mock_data: Any = None
_is_mock: bool = False
def _set_mock_data(self, data: Any):
class MockResult:
def __init__(self, content: Any):
self._content = content
def content(self) -> Any:
return self._content
def json(self) -> Any:
return json.loads(self._content)
def get_mock_data() -> Any:
if type(data) == Exception:
raise data
if hasattr(data, "__next__"):
if d := next(data):
logging.info("MOCK DATA", d)
return MockResult(d)
logging.info("MOCK DATA", data)
return MockResult(data)
self._get_mock_data = get_mock_data
# Data Retrieval Methods
# ==================================================================================
def get_playlists(self) -> Sequence[API.Playlist]:
if playlists := self._get_json(self._make_url("getPlaylists")).playlists:
return playlists.playlist
return []
def get_playlist_details(self, playlist_id: str) -> API.Playlist:
result = self._get_json(self._make_url("getPlaylist"), id=playlist_id).playlist
# TODO (#122) better error (here and elsewhere)
assert result, f"Error getting playlist {playlist_id}"
return result
def create_playlist(
self, name: str, songs: Sequence[API.Song] = None,
) -> Optional[API.Playlist]:
return self._get_json(
self._make_url("createPlaylist"),
name=name,
songId=[s.id for s in songs or []],
).playlist
def update_playlist(
self,
playlist_id: str,
name: str = None,
comment: str = None,
public: bool = None,
song_ids: Sequence[str] = None,
append_song_ids: Sequence[str] = None,
) -> API.Playlist:
if any(x is not None for x in (name, comment, public, append_song_ids)):
self._get_json(
self._make_url("updatePlaylist"),
playlistId=playlist_id,
name=name,
comment=comment,
public=public,
songIdToAdd=append_song_ids,
)
playlist = None
if song_ids is not None:
playlist = self._get_json(
self._make_url("createPlaylist"),
playlistId=playlist_id,
songId=song_ids,
).playlist
# If the call to createPlaylist to update the song IDs returned the playlist,
# return it.
return playlist or self.get_playlist_details(playlist_id)
def delete_playlist(self, playlist_id: str):
self._get_json(self._make_url("deletePlaylist"), id=playlist_id)
def get_cover_art_uri(self, cover_art_id: str, scheme: str, size: int) -> str:
params = {"id": cover_art_id, "size": size, **self._get_params()}
return self._make_url("getCoverArt") + "?" + urlencode(params)
def get_song_uri(self, song_id: str, scheme: str, stream: bool = False) -> str:
params = {"id": song_id, **self._get_params()}
endpoint = "stream" if stream else "download"
return self._make_url(endpoint) + "?" + urlencode(params)
def get_song_details(self, song_id: str) -> API.Song:
song = self._get_json(self._make_url("getSong"), id=song_id).song
assert song, f"Error getting song {song_id}"
return song
def scrobble_song(self, song: API.Song):
self._get(self._make_url("scrobble"), id=song.id)
def get_artists(self) -> Sequence[API.Artist]:
if artist_index := self._get_json(self._make_url("getArtists")).artists:
with open(self.ignored_articles_cache_file, "wb+") as f:
pickle.dump(artist_index.ignored_articles, f)
artists = []
for index in artist_index.index:
artists.extend(index.artist)
return cast(Sequence[API.Artist], artists)
return []
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
)
return artist
def get_ignored_articles(self) -> Set[str]:
ignored_articles = ""
try:
# If we already got the ignored articles from the get_artists, do that here.
with open(self.ignored_articles_cache_file, "rb+") as f:
ignored_articles = pickle.load(f)
except Exception:
# Whatever the exception, fall back on getting from the server.
if artists := self._get_json(self._make_url("getArtists")).artists:
ignored_articles = artists.ignored_articles
return set(ignored_articles.split())
def get_albums(
self, query: AlbumSearchQuery, sort_direction: str = "ascending"
) -> Sequence[API.Album]:
type_ = {
AlbumSearchQuery.Type.RANDOM: "random",
AlbumSearchQuery.Type.NEWEST: "newest",
AlbumSearchQuery.Type.FREQUENT: "frequent",
AlbumSearchQuery.Type.RECENT: "recent",
AlbumSearchQuery.Type.STARRED: "starred",
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME: "alphabeticalByName",
AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST: "alphabeticalByArtist",
AlbumSearchQuery.Type.YEAR_RANGE: "byYear",
AlbumSearchQuery.Type.GENRE: "byGenre",
}[query.type]
extra_args: Dict[str, Any] = {}
if query.type == AlbumSearchQuery.Type.YEAR_RANGE:
assert (year_range := query.year_range)
extra_args = {
"fromYear": year_range[0],
"toYear": year_range[1],
}
elif query.type == AlbumSearchQuery.Type.GENRE:
assert (genre := query.genre)
extra_args = {"genre": genre.name}
albums: List[API.Album] = []
page_size = 50 if query.type == AlbumSearchQuery.Type.RANDOM else 500
offset = 0
def get_page(offset: int) -> Sequence[API.Album]:
album_list = self._get_json(
self._make_url("getAlbumList2"),
type=type_,
size=page_size,
offset=offset,
**extra_args,
).albums
return album_list.album if album_list else []
# Get all pages.
while len(next_page := get_page(offset)) > 0:
albums.extend(next_page)
if query.type == AlbumSearchQuery.Type.RANDOM:
break
offset += page_size
return albums
def get_album(self, album_id: str) -> API.Album:
album = self._get_json(self._make_url("getAlbum"), id=album_id).album
assert album, f"Error getting album {album_id}"
return album
def _get_indexes(self) -> API.Directory:
indexes = self._get_json(self._make_url("getIndexes")).indexes
assert indexes, "Error getting indexes"
with open(self.ignored_articles_cache_file, "wb+") as f:
pickle.dump(indexes.ignored_articles, f)
root_dir_items: List[Dict[str, Any]] = []
for index in indexes.index:
root_dir_items.extend(map(lambda x: {**x, "isDir": True}, index.artist))
return Directory(id="root", _children=root_dir_items)
def get_directory(self, directory_id: str) -> API.Directory:
if directory_id == "root":
return self._get_indexes()
# TODO (#187) make sure to filter out all non-song files
directory = self._get_json(
self._make_url("getMusicDirectory"), id=directory_id
).directory
assert directory, f"Error getting directory {directory_id}"
return directory
def get_genres(self) -> Sequence[API.Genre]:
if genres := self._get_json(self._make_url("getGenres")).genres:
return genres.genre
return []
def get_play_queue(self) -> Optional[API.PlayQueue]:
return self._get_json(self._make_url("getPlayQueue")).play_queue
def save_play_queue(
self,
song_ids: Sequence[int],
current_song_index: int = None,
position: timedelta = None,
):
# TODO (sonic-extensions-api/specification#1) make an extension that allows you
# to save the play queue position by index instead of id.
self._get(
self._make_url("savePlayQueue"),
id=song_ids,
current=song_ids[current_song_index] if current_song_index else None,
position=math.floor(position.total_seconds() * 1000) if position else None,
)
def search(self, query: str) -> API.SearchResult:
result = self._get_json(self._make_url("search3"), query=query).search_result
if not result:
return API.SearchResult(query)
search_result = API.SearchResult(query)
search_result.add_results("albums", result.album)
search_result.add_results("artists", result.artist)
search_result.add_results("songs", result.song)
return search_result

View File

@@ -0,0 +1,340 @@
"""
These are the API objects that are returned by Subsonic.
"""
import hashlib
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Union
import dataclasses_json
from dataclasses_json import (
config,
dataclass_json,
DataClassJsonMixin,
LetterCase,
)
from .. import api_objects as SublimeAPI
# Translation map
decoder_functions = {
datetime: (lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%f%z") if s else None),
timedelta: (lambda s: timedelta(seconds=s) if s else None),
}
encoder_functions = {
datetime: (lambda d: datetime.strftime(d, "%Y-%m-%dT%H:%M:%S.%f%z") if d else None),
timedelta: (lambda t: t.total_seconds() if t else None),
}
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
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
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Genre(SublimeAPI.Genre):
name: str = field(metadata=config(field_name="value"))
song_count: Optional[int] = None
album_count: Optional[int] = None
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Album(SublimeAPI.Album):
name: str
id: Optional[str]
cover_art: Optional[str] = None
song_count: Optional[int] = None
year: Optional[int] = None
duration: Optional[timedelta] = None
created: Optional[datetime] = None
songs: List["Song"] = field(
default_factory=list, metadata=config(field_name="song")
)
play_count: Optional[int] = None
starred: Optional[datetime] = None
# Artist
artist: Optional["ArtistAndArtistInfo"] = field(init=False)
_artist: Optional[str] = field(default=None, metadata=config(field_name="artist"))
artist_id: Optional[str] = None
# Genre
genre: Optional[Genre] = field(init=False)
_genre: Optional[str] = field(default=None, metadata=config(field_name="genre"))
def __post_init__(self):
# Initialize the cross-references
self.artist = (
None
if not self.artist_id and not self._artist
else ArtistAndArtistInfo(id=self.artist_id, name=self._artist)
)
self.genre = None if not self._genre else Genre(self._genre)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class ArtistAndArtistInfo(SublimeAPI.Artist):
name: str
id: Optional[str]
albums: List[Album] = field(
default_factory=list, metadata=config(field_name="album")
)
album_count: Optional[int] = None
cover_art: Optional[str] = None
artist_image_url: Optional[str] = None
starred: Optional[datetime] = None
# Artist Info
similar_artists: List["ArtistAndArtistInfo"] = field(
default_factory=list, metadata=config(field_name="similar_artist")
)
biography: Optional[str] = None
music_brainz_id: Optional[str] = None
last_fm_url: Optional[str] = None
@staticmethod
def _strhash(string: str) -> str:
return hashlib.sha1(bytes(string, "utf8")).hexdigest()
def __post_init__(self):
self.album_count = self.album_count or len(self.albums)
if not self.artist_image_url:
self.artist_image_url = self.cover_art
def augment_with_artist_info(self, artist_info: Optional["ArtistInfo"]):
if artist_info:
for k, v in asdict(artist_info).items():
if v:
setattr(self, k, v)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class ArtistInfo:
similar_artist: List[ArtistAndArtistInfo] = field(default_factory=list)
biography: Optional[str] = None
last_fm_url: Optional[str] = None
artist_image_url: Optional[str] = field(
default=None, metadata=config(field_name="largeImageUrl")
)
music_brainz_id: Optional[str] = None
def __post_init__(self):
if self.artist_image_url:
if self.artist_image_url.endswith("2a96cbd8b46e442fc41c2b86b821562f.png"):
self.artist_image_url = ""
elif self.artist_image_url.endswith("-No_image_available.svg.png"):
self.artist_image_url = ""
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Directory(SublimeAPI.Directory):
id: str
name: Optional[str] = None
title: Optional[str] = None
parent_id: Optional[str] = field(default=None, metadata=config(field_name="parent"))
children: List[Union["Directory", "Song"]] = field(init=False)
_children: List[Dict[str, Any]] = field(
default_factory=list, metadata=config(field_name="child")
)
def __post_init__(self):
self.parent_id = (self.parent_id or "root") if self.id != "root" else None
self.name = self.name or self.title
self.children = [
Directory.from_dict(c) if c.get("isDir") else Song.from_dict(c)
for c in self._children
]
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Song(SublimeAPI.Song, DataClassJsonMixin):
id: str
title: str = field(metadata=config(field_name="name"))
path: Optional[str] = None
parent_id: Optional[str] = field(default=None, metadata=config(field_name="parent"))
duration: Optional[timedelta] = None
# Artist
artist: Optional[ArtistAndArtistInfo] = field(init=False)
_artist: Optional[str] = field(default=None, metadata=config(field_name="artist"))
artist_id: Optional[str] = None
# Album
album: Optional[Album] = field(init=False)
_album: Optional[str] = field(default=None, metadata=config(field_name="album"))
album_id: Optional[str] = None
# Genre
genre: Optional[Genre] = field(init=False)
_genre: Optional[str] = field(default=None, metadata=config(field_name="genre"))
track: Optional[int] = None
disc_number: Optional[int] = None
year: Optional[int] = None
cover_art: Optional[str] = None
user_rating: Optional[int] = None
starred: Optional[datetime] = None
def __post_init__(self):
self.parent_id = (self.parent_id or "root") if self.id != "root" else None
self.artist = (
None
if not self._artist
else ArtistAndArtistInfo(id=self.artist_id, name=self._artist)
)
self.album = (
None if not self._album else Album(id=self.album_id, name=self._album)
)
self.genre = None if not self._genre else Genre(self._genre)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Playlist(SublimeAPI.Playlist):
id: str
name: str
songs: List[Song] = field(default_factory=list, metadata=config(field_name="entry"))
song_count: Optional[int] = field(default=None)
duration: Optional[timedelta] = field(default=None)
created: Optional[datetime] = None
changed: Optional[datetime] = None
comment: Optional[str] = None
owner: Optional[str] = None
public: Optional[bool] = None
cover_art: Optional[str] = None
def __post_init__(self):
if self.songs is None:
return
if self.song_count is None:
self.song_count = len(self.songs)
if self.duration is None:
self.duration = timedelta(
seconds=sum(
s.duration.total_seconds() if s.duration else 0 for s in self.songs
)
)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class PlayQueue(SublimeAPI.PlayQueue):
songs: List[Song] = field(default_factory=list, metadata=config(field_name="entry"))
position: timedelta = timedelta(0)
username: Optional[str] = None
changed: Optional[datetime] = None
changed_by: Optional[str] = None
value: Optional[str] = None
current: Optional[str] = None
current_index: Optional[int] = None
def __post_init__(self):
if pos := self.position:
# The position for this endpoint is in milliseconds instead of seconds
# because the Subsonic API is sometime stupid.
self.position = pos / 1000
if cur := self.current:
self.current_index = [int(s.id) for s in self.songs].index(cur)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Index:
name: str
artist: List[Dict[str, Any]] = field(default_factory=list)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class IndexID3:
name: str
artist: List[ArtistAndArtistInfo] = field(default_factory=list)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class ArtistsID3:
ignored_articles: str
index: List[IndexID3] = field(default_factory=list)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class AlbumList2:
album: List[Album] = field(default_factory=list)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Genres:
genre: List[Genre] = field(default_factory=list)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Indexes:
ignored_articles: str
index: List[Index] = field(default_factory=list)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class Playlists:
playlist: List[Playlist] = field(default_factory=list)
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class SearchResult3:
artist: List[ArtistAndArtistInfo] = field(default_factory=list)
album: List[Album] = field(default_factory=list)
song: List[Song] = field(default_factory=list)
@dataclass
class Response(DataClassJsonMixin):
"""The base Subsonic response object."""
artists: Optional[ArtistsID3] = None
artist: Optional[ArtistAndArtistInfo] = None
artist_info: Optional[ArtistInfo] = field(
default=None, metadata=config(field_name="artistInfo2")
)
albums: Optional[AlbumList2] = field(
default=None, metadata=config(field_name="albumList2")
)
album: Optional[Album] = None
directory: Optional[Directory] = None
genres: Optional[Genres] = None
indexes: Optional[Indexes] = None
playlist: Optional[Playlist] = None
playlists: Optional[Playlists] = None
play_queue: Optional[PlayQueue] = field(
default=None, metadata=config(field_name="playQueue")
)
song: Optional[Song] = None
search_result: Optional[SearchResult3] = field(
default=None, metadata=config(field_name="searchResult3")
)

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.sourceforge.net/restapi"
targetNamespace="http://subsonic.sourceforge.net/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.1.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
</xs:complexType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.1.1">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,500 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.10.2">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="genres" type="sub:Genres" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="songsByGenre" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="Genres">
<xs:sequence>
<xs:element name="genre" type="sub:Genre" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Genre" mixed="true">
<xs:attribute name="songCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
<xs:attribute name="albumCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="genre" type="xs:string" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Songs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="InternetRadioStations">
<xs:sequence>
<xs:element name="internetRadioStation" type="sub:InternetRadioStation" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="InternetRadioStation">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="streamUrl" type="xs:string" use="required"/>
<xs:attribute name="homePageUrl" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Bookmarks">
<xs:sequence>
<xs:element name="bookmark" type="sub:Bookmark" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Bookmark">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="1" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="position" type="xs:long" use="required"/> <!-- In milliseconds -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,548 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.11.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="genres" type="sub:Genres" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="songsByGenre" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo" type="sub:ArtistInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo2" type="sub:ArtistInfo2" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs" type="sub:SimilarSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs2" type="sub:SimilarSongs2" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="Genres">
<xs:sequence>
<xs:element name="genre" type="sub:Genre" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Genre" mixed="true">
<xs:attribute name="songCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
<xs:attribute name="albumCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="genre" type="xs:string" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Songs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="InternetRadioStations">
<xs:sequence>
<xs:element name="internetRadioStation" type="sub:InternetRadioStation" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="InternetRadioStation">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="streamUrl" type="xs:string" use="required"/>
<xs:attribute name="homePageUrl" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Bookmarks">
<xs:sequence>
<xs:element name="bookmark" type="sub:Bookmark" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Bookmark">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="1" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="position" type="xs:long" use="required"/> <!-- In milliseconds -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfoBase">
<xs:sequence>
<xs:element name="biography" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfo">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ArtistInfo2">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SimilarSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SimilarSongs2">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,563 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.12.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="genres" type="sub:Genres" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="songsByGenre" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="playQueue" type="sub:PlayQueue" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo" type="sub:ArtistInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo2" type="sub:ArtistInfo2" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs" type="sub:SimilarSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs2" type="sub:SimilarSongs2" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="Genres">
<xs:sequence>
<xs:element name="genre" type="sub:Genre" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Genre" mixed="true">
<xs:attribute name="songCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
<xs:attribute name="albumCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="genre" type="xs:string" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Songs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="InternetRadioStations">
<xs:sequence>
<xs:element name="internetRadioStation" type="sub:InternetRadioStation" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="InternetRadioStation">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="streamUrl" type="xs:string" use="required"/>
<xs:attribute name="homePageUrl" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Bookmarks">
<xs:sequence>
<xs:element name="bookmark" type="sub:Bookmark" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Bookmark">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="1" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="position" type="xs:long" use="required"/> <!-- In milliseconds -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="PlayQueue">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="current" type="xs:int" use="optional"/> <!-- ID of currently playing track -->
<xs:attribute name="position" type="xs:long" use="optional"/> <!-- Position in milliseconds of currently playing track -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
<xs:attribute name="changedBy" type="xs:string" use="required"/> <!-- Name of client app -->
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfoBase">
<xs:sequence>
<xs:element name="biography" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfo">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ArtistInfo2">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SimilarSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SimilarSongs2">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:sequence>
<xs:element name="folder" type="xs:int" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.12.0 -->
</xs:sequence>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,588 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.13.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="genres" type="sub:Genres" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="songsByGenre" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="newestPodcasts" type="sub:NewestPodcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="playQueue" type="sub:PlayQueue" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo" type="sub:ArtistInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo2" type="sub:ArtistInfo2" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs" type="sub:SimilarSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs2" type="sub:SimilarSongs2" minOccurs="1" maxOccurs="1"/>
<xs:element name="topSongs" type="sub:TopSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
</xs:complexType>
<xs:complexType name="Genres">
<xs:sequence>
<xs:element name="genre" type="sub:Genre" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Genre" mixed="true">
<xs:attribute name="songCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
<xs:attribute name="albumCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="genre" type="xs:string" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
<xs:attribute name="originalWidth" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="originalHeight" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <!--Added in 1.13.0-->
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Songs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="originalImageUrl" type="xs:string" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="NewestPodcasts">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="channelId" type="xs:string" use="required"/> <!-- Added in 1.13.0 -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="InternetRadioStations">
<xs:sequence>
<xs:element name="internetRadioStation" type="sub:InternetRadioStation" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="InternetRadioStation">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="streamUrl" type="xs:string" use="required"/>
<xs:attribute name="homePageUrl" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Bookmarks">
<xs:sequence>
<xs:element name="bookmark" type="sub:Bookmark" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Bookmark">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="1" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="position" type="xs:long" use="required"/> <!-- In milliseconds -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="PlayQueue">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="current" type="xs:int" use="optional"/> <!-- ID of currently playing track -->
<xs:attribute name="position" type="xs:long" use="optional"/> <!-- Position in milliseconds of currently playing track -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
<xs:attribute name="changedBy" type="xs:string" use="required"/> <!-- Name of client app -->
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfoBase">
<xs:sequence>
<xs:element name="biography" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfo">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ArtistInfo2">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SimilarSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SimilarSongs2">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="TopSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="licenseExpires" type="xs:dateTime" use="optional"/>
<xs:attribute name="trialExpires" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:sequence>
<xs:element name="folder" type="xs:int" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.12.0 -->
</xs:sequence>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="maxBitRate" type="xs:int" use="optional"/> <!-- In Kbps, added in 1.13.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,632 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.14.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="genres" type="sub:Genres" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="videoInfo" type="sub:VideoInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="songsByGenre" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="newestPodcasts" type="sub:NewestPodcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="playQueue" type="sub:PlayQueue" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumInfo" type="sub:AlbumInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo" type="sub:ArtistInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo2" type="sub:ArtistInfo2" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs" type="sub:SimilarSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs2" type="sub:SimilarSongs2" minOccurs="1" maxOccurs="1"/>
<xs:element name="topSongs" type="sub:TopSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
</xs:complexType>
<xs:complexType name="Genres">
<xs:sequence>
<xs:element name="genre" type="sub:Genre" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Genre" mixed="true">
<xs:attribute name="songCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
<xs:attribute name="albumCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="playCount" type="xs:long" use="optional"/> <!-- Added in 1.14.0 -->
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="genre" type="xs:string" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="VideoInfo">
<xs:sequence>
<xs:element name="captions" type="sub:Captions" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="audioTrack" type="sub:AudioTrack" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="conversion" type="sub:VideoConversion" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Captions">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="AudioTrack">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
<xs:attribute name="languageCode" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="VideoConversion">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/> <!-- In Kbps -->
<xs:attribute name="audioTrackId" type="xs:int" use="optional"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="playCount" type="xs:long" use="optional"/> <!-- Added in 1.14.0 -->
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="playCount" type="xs:long" use="optional"/> <!-- Added in 1.14.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
<xs:attribute name="originalWidth" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="originalHeight" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <!--Added in 1.13.0-->
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Songs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="originalImageUrl" type="xs:string" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="NewestPodcasts">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="channelId" type="xs:string" use="required"/> <!-- Added in 1.13.0 -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="InternetRadioStations">
<xs:sequence>
<xs:element name="internetRadioStation" type="sub:InternetRadioStation" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="InternetRadioStation">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="streamUrl" type="xs:string" use="required"/>
<xs:attribute name="homePageUrl" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Bookmarks">
<xs:sequence>
<xs:element name="bookmark" type="sub:Bookmark" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Bookmark">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="1" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="position" type="xs:long" use="required"/> <!-- In milliseconds -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="PlayQueue">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="current" type="xs:int" use="optional"/> <!-- ID of currently playing track -->
<xs:attribute name="position" type="xs:long" use="optional"/> <!-- Position in milliseconds of currently playing track -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
<xs:attribute name="changedBy" type="xs:string" use="required"/> <!-- Name of client app -->
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumInfo">
<xs:sequence>
<xs:element name="notes" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfoBase">
<xs:sequence>
<xs:element name="biography" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfo">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ArtistInfo2">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SimilarSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SimilarSongs2">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="TopSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="licenseExpires" type="xs:dateTime" use="optional"/>
<xs:attribute name="trialExpires" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:sequence>
<xs:element name="folder" type="xs:int" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.12.0 -->
</xs:sequence>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="maxBitRate" type="xs:int" use="optional"/> <!-- In Kbps, added in 1.13.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="videoConversionRole" type="xs:boolean" use="required"/> <!-- Added in 1.14.0 -->
<xs:attribute name="avatarLastChanged" type="xs:dateTime" use="optional"/> <!-- Added in 1.14.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,638 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.15.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="genres" type="sub:Genres" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="videoInfo" type="sub:VideoInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="songsByGenre" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="newestPodcasts" type="sub:NewestPodcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="playQueue" type="sub:PlayQueue" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumInfo" type="sub:AlbumInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo" type="sub:ArtistInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo2" type="sub:ArtistInfo2" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs" type="sub:SimilarSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs2" type="sub:SimilarSongs2" minOccurs="1" maxOccurs="1"/>
<xs:element name="topSongs" type="sub:TopSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="scanStatus" type="sub:ScanStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
</xs:complexType>
<xs:complexType name="Genres">
<xs:sequence>
<xs:element name="genre" type="sub:Genre" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Genre" mixed="true">
<xs:attribute name="songCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
<xs:attribute name="albumCount" type="xs:int" use="required"/> <!-- Added in 1.10.2 -->
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="ignoredArticles" type="xs:string" use="required"/> <!-- Added in 1.10.0 -->
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="playCount" type="xs:long" use="optional"/> <!-- Added in 1.14.0 -->
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="genre" type="xs:string" use="optional"/> <!-- Added in 1.10.1 -->
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="VideoInfo">
<xs:sequence>
<xs:element name="captions" type="sub:Captions" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="audioTrack" type="sub:AudioTrack" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="conversion" type="sub:VideoConversion" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Captions">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="AudioTrack">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
<xs:attribute name="languageCode" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="VideoConversion">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/> <!-- In Kbps -->
<xs:attribute name="audioTrackId" type="xs:int" use="optional"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.10.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="playCount" type="xs:long" use="optional"/> <!-- Added in 1.14.0 -->
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="playCount" type="xs:long" use="optional"/> <!-- Added in 1.14.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
<xs:attribute name="originalWidth" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="originalHeight" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <!--Added in 1.13.0-->
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Songs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="originalImageUrl" type="xs:string" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="NewestPodcasts">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="channelId" type="xs:string" use="required"/> <!-- Added in 1.13.0 -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="InternetRadioStations">
<xs:sequence>
<xs:element name="internetRadioStation" type="sub:InternetRadioStation" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="InternetRadioStation">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="streamUrl" type="xs:string" use="required"/>
<xs:attribute name="homePageUrl" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Bookmarks">
<xs:sequence>
<xs:element name="bookmark" type="sub:Bookmark" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Bookmark">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="1" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="position" type="xs:long" use="required"/> <!-- In milliseconds -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="PlayQueue">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="current" type="xs:int" use="optional"/> <!-- ID of currently playing track -->
<xs:attribute name="position" type="xs:long" use="optional"/> <!-- Position in milliseconds of currently playing track -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
<xs:attribute name="changedBy" type="xs:string" use="required"/> <!-- Name of client app -->
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumInfo">
<xs:sequence>
<xs:element name="notes" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfoBase">
<xs:sequence>
<xs:element name="biography" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfo">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ArtistInfo2">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SimilarSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SimilarSongs2">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="TopSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="licenseExpires" type="xs:dateTime" use="optional"/>
<xs:attribute name="trialExpires" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ScanStatus">
<xs:attribute name="scanning" type="xs:boolean" use="required"/>
<xs:attribute name="count" type="xs:long" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:sequence>
<xs:element name="folder" type="xs:int" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.12.0 -->
</xs:sequence>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="maxBitRate" type="xs:int" use="optional"/> <!-- In Kbps, added in 1.13.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="videoConversionRole" type="xs:boolean" use="required"/> <!-- Added in 1.14.0 -->
<xs:attribute name="avatarLastChanged" type="xs:dateTime" use="optional"/> <!-- Added in 1.14.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,199 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.2">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,214 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.3.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,226 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.4.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="required"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,227 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.5.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="required"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
</xs:complexType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,306 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.6.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="required"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
</xs:complexType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,319 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.7.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:Playlist" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
</xs:complexType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:PlaylistIdAndName" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PlaylistIdAndName">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:complexContent>
<xs:extension base="sub:PlaylistIdAndName">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,448 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.8.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RandomSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,488 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:sub="http://subsonic.org/restapi"
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.9.0">
<xs:element name="subsonic-response" type="sub:Response"/>
<xs:complexType name="Response">
<xs:choice minOccurs="0" maxOccurs="1">
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
<xs:element name="genres" type="sub:Genres" minOccurs="1" maxOccurs="1"/>
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
<xs:element name="randomSongs" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="songsByGenre" type="sub:Songs" minOccurs="1" maxOccurs="1"/>
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
<xs:attribute name="version" type="sub:Version" use="required"/>
</xs:complexType>
<xs:simpleType name="ResponseStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="ok"/>
<xs:enumeration value="failed"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Version">
<xs:restriction base="xs:string">
<xs:pattern value="\d+\.\d+\.\d+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="MusicFolders">
<xs:sequence>
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="MusicFolder">
<xs:attribute name="id" type="xs:int" use="required"/>
<xs:attribute name="name" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Indexes">
<xs:sequence>
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
</xs:sequence>
<xs:attribute name="lastModified" type="xs:long" use="required"/>
</xs:complexType>
<xs:complexType name="Index">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Artist">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Genres">
<xs:sequence>
<xs:element name="genre" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistsID3">
<xs:sequence>
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="IndexID3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="ArtistID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="albumCount" type="xs:int" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="ArtistWithAlbumsID3">
<xs:complexContent>
<xs:extension base="sub:ArtistID3">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="AlbumID3">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="artistId" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="songCount" type="xs:int" use="required"/>
<xs:attribute name="duration" type="xs:int" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="AlbumWithSongsID3">
<xs:complexContent>
<xs:extension base="sub:AlbumID3">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="Videos">
<xs:sequence>
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Directory">
<xs:sequence>
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="name" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="Child">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="parent" type="xs:string" use="optional"/>
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
<xs:attribute name="title" type="xs:string" use="required"/>
<xs:attribute name="album" type="xs:string" use="optional"/>
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="track" type="xs:int" use="optional"/>
<xs:attribute name="year" type="xs:int" use="optional"/>
<xs:attribute name="genre" type="xs:string" use="optional"/>
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
<xs:attribute name="size" type="xs:long" use="optional"/>
<xs:attribute name="contentType" type="xs:string" use="optional"/>
<xs:attribute name="suffix" type="xs:string" use="optional"/>
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
<xs:attribute name="duration" type="xs:int" use="optional"/>
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
</xs:complexType>
<xs:simpleType name="MediaType">
<xs:restriction base="xs:string">
<xs:enumeration value="music"/>
<xs:enumeration value="podcast"/>
<xs:enumeration value="audiobook"/>
<xs:enumeration value="video"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="UserRating">
<xs:restriction base="xs:int">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="5"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AverageRating">
<xs:restriction base="xs:double">
<xs:minInclusive value="1.0"/>
<xs:maxInclusive value="5.0"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="NowPlaying">
<xs:sequence>
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="NowPlayingEntry">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
<xs:attribute name="playerId" type="xs:int" use="required"/>
<xs:attribute name="playerName" type="xs:string" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!--Deprecated-->
<xs:complexType name="SearchResult">
<xs:sequence>
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="offset" type="xs:int" use="required"/>
<xs:attribute name="totalHits" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="SearchResult2">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SearchResult3">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlists">
<xs:sequence>
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Playlist">
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
<xs:complexContent>
<xs:extension base="sub:Playlist">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="JukeboxStatus">
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
<xs:attribute name="playing" type="xs:boolean" use="required"/>
<xs:attribute name="gain" type="xs:float" use="required"/>
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
</xs:complexType>
<xs:complexType name="JukeboxPlaylist">
<xs:complexContent>
<xs:extension base="sub:JukeboxStatus">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ChatMessages">
<xs:sequence>
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChatMessage">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="time" type="xs:long" use="required"/>
<xs:attribute name="message" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="AlbumList">
<xs:sequence>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="AlbumList2">
<xs:sequence>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Songs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Lyrics" mixed="true">
<xs:attribute name="artist" type="xs:string" use="optional"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Podcasts">
<xs:sequence>
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="PodcastChannel">
<xs:sequence>
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="title" type="xs:string" use="optional"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="PodcastEpisode">
<xs:complexContent>
<xs:extension base="sub:Child">
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="PodcastStatus">
<xs:restriction base="xs:string">
<xs:enumeration value="new"/>
<xs:enumeration value="downloading"/>
<xs:enumeration value="completed"/>
<xs:enumeration value="error"/>
<xs:enumeration value="deleted"/>
<xs:enumeration value="skipped"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="InternetRadioStations">
<xs:sequence>
<xs:element name="internetRadioStation" type="sub:InternetRadioStation" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="InternetRadioStation">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="streamUrl" type="xs:string" use="required"/>
<xs:attribute name="homePageUrl" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="Bookmarks">
<xs:sequence>
<xs:element name="bookmark" type="sub:Bookmark" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Bookmark">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="1" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="position" type="xs:long" use="required"/> <!-- In milliseconds -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Share">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="url" type="xs:string" use="required"/>
<xs:attribute name="description" type="xs:string" use="optional"/>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="created" type="xs:dateTime" use="required"/>
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
<xs:attribute name="visitCount" type="xs:int" use="required"/>
</xs:complexType>
<xs:complexType name="Starred">
<xs:sequence>
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="License">
<xs:attribute name="valid" type="xs:boolean" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/>
<xs:attribute name="key" type="xs:string" use="optional"/>
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
</xs:complexType>
<xs:complexType name="Users">
<xs:sequence>
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="User">
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
</xs:complexType>
<xs:complexType name="Error">
<xs:attribute name="code" type="xs:int" use="required"/>
<xs:attribute name="message" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
import hashlib
import logging import logging
import os import os
import pickle
from dataclasses import asdict, dataclass, field, fields
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional from pathlib import Path
from typing import List, Optional
try: import yaml
import keyring
has_keyring = True from sublime.ui.state import UIState
except ImportError:
has_keyring = False
class ReplayGainType(Enum): class ReplayGainType(Enum):
@@ -17,94 +18,63 @@ class ReplayGainType(Enum):
ALBUM = 2 ALBUM = 2
def as_string(self) -> str: def as_string(self) -> str:
return ['no', 'track', 'album'][self.value] return ["no", "track", "album"][self.value]
@staticmethod @staticmethod
def from_string(replay_gain_type: str) -> 'ReplayGainType': def from_string(replay_gain_type: str) -> "ReplayGainType":
return { return {
'no': ReplayGainType.NO, "no": ReplayGainType.NO,
'disabled': ReplayGainType.NO, "disabled": ReplayGainType.NO,
'track': ReplayGainType.TRACK, "track": ReplayGainType.TRACK,
'album': ReplayGainType.ALBUM, "album": ReplayGainType.ALBUM,
}[replay_gain_type.lower()] }[replay_gain_type.lower()]
@dataclass
class ServerConfiguration: class ServerConfiguration:
version: int name: str = "Default"
name: str server_address: str = "http://yourhost"
server_address: str local_network_address: str = ""
local_network_address: str local_network_ssid: str = ""
local_network_ssid: str username: str = ""
username: str password: str = ""
_password: str sync_enabled: bool = True
sync_enabled: bool disable_cert_verify: bool = False
disable_cert_verify: bool version: int = 0
def __init__(
self,
name: str = 'Default',
server_address: str = 'http://yourhost',
local_network_address: str = '',
local_network_ssid: str = '',
username: str = '',
password: str = '',
sync_enabled: bool = True,
disable_cert_verify: bool = False,
):
self.name = name
self.server_address = server_address
self.local_network_address = local_network_address
self.local_network_ssid = local_network_ssid
self.username = username
self.sync_enabled = sync_enabled
self.disable_cert_verify = disable_cert_verify
# Try to save the password in the keyring, but if we can't, then save
# it in the config JSON.
if not has_keyring:
self._password = password
else:
try:
keyring.set_password(
'com.sumnerevans.SublimeMusic',
f'{self.username}@{self.server_address}',
password,
)
except Exception:
self._password = password
def migrate(self): def migrate(self):
# Try and migrate to use the system keyring, but if it fails, then we self.version = 0
# don't care.
if self._password and has_keyring:
try:
keyring.set_password(
'com.sumnerevans.SublimeMusic',
f'{self.username}@{self.server_address}',
self._password,
)
self._password = None
except Exception:
pass
@property _strhash: Optional[str] = None
def password(self) -> str:
if not has_keyring:
return self._password
try: def strhash(self) -> str:
return keyring.get_password( # TODO (#197): make this configurable by the adapters the combination of the
'com.sumnerevans.SublimeMusic', # hashes will be the hash dir
f'{self.username}@{self.server_address}', """
) Returns the MD5 hash of the server's name, server address, and
except Exception: username. This should be used whenever it's necessary to uniquely
return self._password identify the server, rather than using the name (which is not
necessarily unique).
>>> sc = ServerConfiguration(
... name='foo',
... server_address='bar',
... username='baz',
... )
>>> sc.strhash()
'6df23dc03f9b54cc38a0fc1483df6e21'
"""
if not self._strhash:
server_info = self.name + self.server_address + self.username
self._strhash = hashlib.md5(server_info.encode("utf-8")).hexdigest()
return self._strhash
@dataclass
class AppConfiguration: class AppConfiguration:
servers: List[ServerConfiguration] = [] servers: List[ServerConfiguration] = field(default_factory=list)
current_server: int = -1 current_server_index: int = -1
_cache_location: str = '' cache_location: str = ""
max_cache_size_mb: int = -1 # -1 means unlimited max_cache_size_mb: int = -1 # -1 means unlimited
always_stream: bool = False # always stream instead of downloading songs always_stream: bool = False # always stream instead of downloading songs
download_on_stream: bool = True # also download when streaming a song download_on_stream: bool = True # also download when streaming a song
@@ -115,53 +85,98 @@ class AppConfiguration:
version: int = 3 version: int = 3
serve_over_lan: bool = True serve_over_lan: bool = True
replay_gain: ReplayGainType = ReplayGainType.NO replay_gain: ReplayGainType = ReplayGainType.NO
filename: Optional[Path] = None
def to_json(self) -> Dict[str, Any]: @staticmethod
exclude = ('servers', 'replay_gain') def load_from_file(filename: Path) -> "AppConfiguration":
json_object = { args = {}
k: getattr(self, k) if filename.exists():
for k in self.__annotations__.keys() with open(filename, "r") as f:
if k not in exclude field_names = {f.name for f in fields(AppConfiguration)}
} args = yaml.load(f, Loader=yaml.CLoader).items()
json_object.update( args = dict(filter(lambda kv: kv[0] in field_names, args))
{
'servers': [s.__dict__ for s in self.servers], config = AppConfiguration(**args)
'replay_gain': config.filename = filename
getattr(self, 'replay_gain', ReplayGainType.NO).value,
}) return config
return json_object
def __post_init__(self):
# Default the cache_location to ~/.local/share/sublime-music
if not self.cache_location:
path = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
path = path.expanduser().joinpath("sublime-music").resolve()
self.cache_location = path.as_posix()
# Deserialize the YAML into the ServerConfiguration object.
if len(self.servers) > 0 and type(self.servers[0]) != ServerConfiguration:
self.servers = [ServerConfiguration(**sc) for sc in self.servers]
self._state = None
self._current_server_hash = None
def migrate(self): def migrate(self):
for server in self.servers: for server in self.servers:
server.migrate() server.migrate()
if (getattr(self, 'version') or 0) < 2:
logging.info('Migrating app configuration to version 2.')
logging.info('Setting serve_over_lan to True')
self.serve_over_lan = True
if (getattr(self, 'version') or 0) < 3:
logging.info('Migrating app configuration to version 3.')
logging.info('Setting replay_gain to ReplayGainType.NO')
self.replay_gain = ReplayGainType.NO
self.version = 3 self.version = 3
self.state.migrate()
@property
def cache_location(self) -> str:
if (hasattr(self, '_cache_location')
and self._cache_location is not None
and self._cache_location != ''):
return self._cache_location
else:
default_cache_location = (
os.environ.get('XDG_DATA_HOME')
or os.path.expanduser('~/.local/share'))
return os.path.join(default_cache_location, 'sublime-music')
@property @property
def server(self) -> Optional[ServerConfiguration]: def server(self) -> Optional[ServerConfiguration]:
if 0 <= self.current_server < len(self.servers): if 0 <= self.current_server_index < len(self.servers):
return self.servers[self.current_server] return self.servers[self.current_server_index]
return None return None
@property
def state(self) -> UIState:
server = self.server
if not server:
return UIState()
# If the server has changed, then retrieve the new server's state.
if self._current_server_hash != server.strhash():
self.load_state()
return self._state
def load_state(self):
self._state = UIState()
if not self.server:
return
self._current_server_hash = self.server.strhash()
if self.state_file_location.exists():
try:
with open(self.state_file_location, "rb") as f:
self._state = pickle.load(f)
except Exception:
logging.warning(f"Couldn't load state from {self.state_file_location}")
# Just ignore any errors, it is only UI state.
self._state = UIState()
# Do the import in the function to avoid circular imports.
from sublime.adapters import AdapterManager
AdapterManager.reset(self)
@property
def state_file_location(self) -> Path:
assert self.server is not None
server_hash = self.server.strhash()
state_file_location = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
return state_file_location.expanduser().joinpath(
"sublime-music", server_hash, "state.pickle"
)
def save(self):
# Save the config as YAML.
self.filename.parent.mkdir(parents=True, exist_ok=True)
with open(self.filename, "w+") as f:
f.write(yaml.dump(asdict(self)))
# Save the state for the current server.
self.state_file_location.parent.mkdir(parents=True, exist_ok=True)
with open(self.state_file_location, "wb+") as f:
pickle.dump(self.state, f)

3
sublime/dbus/__init__.py Normal file
View File

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

404
sublime/dbus/manager.py Normal file
View File

@@ -0,0 +1,404 @@
import functools
import logging
import os
import re
from collections import defaultdict
from datetime import timedelta
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple
from deepdiff import DeepDiff
from gi.repository import Gio, GLib
from sublime.adapters import AdapterManager, CacheMissError
from sublime.config import AppConfiguration
from sublime.players import Player
from sublime.ui.state import RepeatType
def dbus_propagate(param_self: Any = None) -> Callable:
"""Wraps a function which causes changes to DBus properties."""
def decorator(function: Callable) -> Callable:
@functools.wraps(function)
def wrapper(*args):
function(*args)
if (param_self or args[0]).dbus_manager:
(param_self or args[0]).dbus_manager.property_diff()
return wrapper
return decorator
class DBusManager:
second_microsecond_conversion = 1000000
current_state: Dict = {}
def __init__(
self,
connection: Gio.DBusConnection,
do_on_method_call: Callable[
[
Gio.DBusConnection,
str,
str,
str,
str,
GLib.Variant,
Gio.DBusMethodInvocation,
],
None,
],
on_set_property: Callable[
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None
],
get_config_and_player: Callable[[], Tuple[AppConfiguration, Optional[Player]]],
):
self.get_config_and_player = get_config_and_player
self.do_on_method_call = do_on_method_call
self.on_set_property = on_set_property
self.connection = connection
def dbus_name_acquired(connection: Gio.DBusConnection, name: str):
specs = [
"org.mpris.MediaPlayer2.xml",
"org.mpris.MediaPlayer2.Player.xml",
"org.mpris.MediaPlayer2.Playlists.xml",
"org.mpris.MediaPlayer2.TrackList.xml",
]
for spec in specs:
spec_path = os.path.join(
os.path.dirname(__file__), f"mpris_specs/{spec}",
)
with open(spec_path) as f:
node_info = Gio.DBusNodeInfo.new_for_xml(f.read())
connection.register_object(
"/org/mpris/MediaPlayer2",
node_info.interfaces[0],
self.on_method_call,
self.on_get_property,
self.on_set_property,
)
# TODO (#127): I have no idea what to do here.
def dbus_name_lost(*args):
pass
self.bus_number = Gio.bus_own_name_on_connection(
connection,
"org.mpris.MediaPlayer2.sublimemusic",
Gio.BusNameOwnerFlags.NONE,
dbus_name_acquired,
dbus_name_lost,
)
def shutdown(self):
logging.info("DBusManager is shutting down.")
self.property_diff()
Gio.bus_unown_name(self.bus_number)
def on_get_property(
self,
connection: Gio.DBusConnection,
sender: str,
path: str,
interface: str,
property_name: str,
) -> GLib.Variant:
value = self.property_dict().get(interface, {}).get(property_name)
return DBusManager.to_variant(value)
def on_method_call(
self,
connection: Gio.DBusConnection,
sender: str,
path: str,
interface: str,
method: str,
params: GLib.Variant,
invocation: Gio.DBusMethodInvocation,
):
# TODO (#127): I don't really know if this works.
if interface == "org.freedesktop.DBus.Properties":
if method == "Get":
invocation.return_value(
self.on_get_property(connection, sender, path, interface, *params)
)
elif method == "Set":
self.on_set_property(connection, sender, path, interface, *params)
elif method == "GetAll":
all_properties = {
k: DBusManager.to_variant(v)
for k, v in self.property_dict()[interface].items()
}
invocation.return_value(GLib.Variant("(a{sv})", (all_properties,)))
return
self.do_on_method_call(
connection, sender, path, interface, method, params, invocation,
)
@staticmethod
def to_variant(value: Any) -> GLib.Variant:
if callable(value):
return DBusManager.to_variant(value())
if isinstance(value, GLib.Variant):
return value
if type(value) == tuple:
return GLib.Variant(*value)
if type(value) == dict:
return GLib.Variant(
"a{sv}", {k: DBusManager.to_variant(v) for k, v in value.items()},
)
variant_type = {list: "as", str: "s", int: "i", float: "d", bool: "b"}.get(
type(value)
)
if not variant_type:
return value
return GLib.Variant(variant_type, value)
def property_dict(self) -> Dict[str, Any]:
config, player = self.get_config_and_player()
state = config.state
has_current_song = state.current_song is not None
has_next_song = False
if state.repeat_type in (RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG):
has_next_song = True
elif has_current_song:
has_next_song = state.current_song_index < len(state.play_queue) - 1
active_playlist = self.get_active_playlist(state.active_playlist_id)
playlist_count = 0
try:
get_playlists_result = AdapterManager.get_playlists(allow_download=False)
if get_playlists_result.data_is_available:
playlist_count = len(get_playlists_result.result())
except Exception:
pass
return {
"org.mpris.MediaPlayer2": {
"CanQuit": True,
"CanRaise": True,
"HasTrackList": True,
"Identity": "Sublime Music",
"DesktopEntry": "sublime-music",
"SupportedUriSchemes": [],
"SupportedMimeTypes": [],
},
"org.mpris.MediaPlayer2.Player": {
"PlaybackStatus": {
(False, False): "Stopped",
(False, True): "Stopped",
(True, False): "Paused",
(True, True): "Playing",
}[player is not None and player.song_loaded, state.playing],
"LoopStatus": state.repeat_type.as_mpris_loop_status(),
"Rate": 1.0,
"Shuffle": state.shuffle_on,
"Metadata": self.get_mpris_metadata(
state.current_song_index, state.play_queue,
)
if state.current_song
else {},
"Volume": 0.0 if state.is_muted else state.volume / 100,
"Position": (
"x",
int(
max(state.song_progress.total_seconds(), 0)
* self.second_microsecond_conversion
),
),
"MinimumRate": 1.0,
"MaximumRate": 1.0,
"CanGoNext": has_current_song and has_next_song,
"CanGoPrevious": has_current_song,
"CanPlay": True,
"CanPause": True,
"CanSeek": True,
"CanControl": True,
},
"org.mpris.MediaPlayer2.TrackList": {
"Tracks": self.get_dbus_playlist(state.play_queue),
"CanEditTracks": False,
},
"org.mpris.MediaPlayer2.Playlists": {
"PlaylistCount": playlist_count,
"Orderings": ["Alphabetical", "Created", "Modified"],
"ActivePlaylist": ("(b(oss))", active_playlist),
},
}
@functools.lru_cache(maxsize=10)
def get_active_playlist(
self, active_playlist_id: Optional[str]
) -> Tuple[bool, GLib.Variant]:
if not active_playlist_id or not AdapterManager.can_get_playlist_details():
return (False, GLib.Variant("(oss)", ("/", "", "")))
try:
playlist = AdapterManager.get_playlist_details(
active_playlist_id, allow_download=False
).result()
try:
cover_art = AdapterManager.get_cover_art_filename(
playlist.cover_art, allow_download=False
).result()
except CacheMissError:
cover_art = ""
return (
True,
GLib.Variant(
"(oss)", ("/playlist/" + playlist.id, playlist.name, cover_art)
),
)
except Exception:
logging.exception("Couldn't get playlist details")
return (False, GLib.Variant("(oss)", ("/", "", "")))
@functools.lru_cache(maxsize=10)
def get_mpris_metadata(
self, idx: int, play_queue: Tuple[str, ...]
) -> Dict[str, Any]:
try:
song = AdapterManager.get_song_details(
play_queue[idx], allow_download=False
).result()
except Exception:
return {}
trackid = self.get_dbus_playlist(play_queue)[idx]
duration = (
"x",
int(
(song.duration or timedelta(0)).total_seconds()
* self.second_microsecond_conversion
),
)
try:
cover_art = AdapterManager.get_cover_art_filename(
song.cover_art, allow_download=False
).result()
except CacheMissError:
cover_art = ""
artist_name = song.artist.name if song.artist else ""
return {
"mpris:trackid": trackid,
"mpris:length": duration,
"mpris:artUrl": cover_art,
# TODO (#71) use walrus once MYPY isn't retarded
"xesam:album": (song.album.name if song.album else ""),
"xesam:albumArtist": [artist_name],
"xesam:artist": artist_name,
"xesam:title": song.title,
}
@functools.lru_cache(maxsize=10)
def get_dbus_playlist(self, play_queue: Tuple[str, ...]) -> List[str]:
seen_counts: DefaultDict[str, int] = defaultdict(int)
tracks = []
for song_id in play_queue:
id_ = seen_counts[song_id]
tracks.append(f"/song/{song_id}/{id_}")
seen_counts[song_id] += 1
return tracks
diff_parse_re = re.compile(r"root\['(.*?)'\]\['(.*?)'\](?:\[.*\])?")
def property_diff(self):
new_property_dict = self.property_dict()
diff = DeepDiff(self.current_state, new_property_dict)
changes = defaultdict(dict)
for path, change in diff.get("values_changed", {}).items():
interface, property_name = self.diff_parse_re.match(path).groups()
changes[interface][property_name] = change["new_value"]
if diff.get("dictionary_item_added"):
changes = new_property_dict
for interface, changed_props in changes.items():
# If the metadata has changed, just make the entire Metadata object
# part of the update.
if "Metadata" in changed_props.keys():
changed_props["Metadata"] = new_property_dict[interface]["Metadata"]
# Special handling for when the position changes (a seek).
# Technically, I'm sending this signal too often, but I don't think
# it really matters.
if (
interface == "org.mpris.MediaPlayer2.Player"
and "Position" in changed_props
):
self.connection.emit_signal(
None,
"/org/mpris/MediaPlayer2",
interface,
"Seeked",
GLib.Variant("(x)", (changed_props["Position"][1],)),
)
# Do not emit the property change.
del changed_props["Position"]
# Special handling for when the track list changes.
# Technically, I'm supposed to use `TrackAdded` and `TrackRemoved`
# signals when minor changes occur, but the docs also say that:
#
# > It is left up to the implementation to decide when a change to
# > the track list is invasive enough that this signal should be
# > emitted instead of a series of TrackAdded and TrackRemoved
# > signals.
#
# So I think that any change is invasive enough that I should use
# this signal.
if (
interface == "org.mpris.MediaPlayer2.TrackList"
and "Tracks" in changed_props
):
track_list = changed_props["Tracks"]
if len(track_list) > 0:
current_track = new_property_dict["org.mpris.MediaPlayer2.Player"][
"Metadata"
].get("mpris:trackid", track_list[0])
self.connection.emit_signal(
None,
"/org/mpris/MediaPlayer2",
interface,
"TrackListReplaced",
GLib.Variant("(aoo)", (track_list, current_track)),
)
self.connection.emit_signal(
None,
"/org/mpris/MediaPlayer2",
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
GLib.Variant(
"(sa{sv}as)",
(
interface,
{
k: DBusManager.to_variant(v)
for k, v in changed_props.items()
},
[],
),
),
)
# Update state for next diff.
self.current_state = new_property_dict

View File

@@ -1,402 +0,0 @@
import functools
import os
import re
from collections import defaultdict
from typing import Any, Callable, DefaultDict, Dict, List, Tuple
from deepdiff import DeepDiff
from gi.repository import Gio, GLib
from .cache_manager import CacheManager
from .players import Player
from .state_manager import ApplicationState, RepeatType
def dbus_propagate(param_self: Any = None) -> Callable:
"""
Wraps a function which causes changes to DBus properties.
"""
def decorator(function: Callable) -> Callable:
@functools.wraps(function)
def wrapper(*args):
function(*args)
if (param_self or args[0]).dbus_manager:
(param_self or args[0]).dbus_manager.property_diff()
return wrapper
return decorator
class DBusManager:
second_microsecond_conversion = 1000000
current_state: Dict = {}
def __init__(
self,
connection: Gio.DBusConnection,
do_on_method_call: Callable[[
Gio.DBusConnection,
str,
str,
str,
str,
GLib.Variant,
Gio.DBusMethodInvocation,
], None],
on_set_property: Callable[
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None],
get_state_and_player: Callable[[], Tuple[ApplicationState, Player]],
):
self.get_state_and_player = get_state_and_player
self.do_on_method_call = do_on_method_call
self.on_set_property = on_set_property
self.connection = connection
def dbus_name_acquired(connection: Gio.DBusConnection, name: str):
specs = [
'org.mpris.MediaPlayer2.xml',
'org.mpris.MediaPlayer2.Player.xml',
'org.mpris.MediaPlayer2.Playlists.xml',
'org.mpris.MediaPlayer2.TrackList.xml',
]
for spec in specs:
spec_path = os.path.join(
os.path.dirname(__file__),
f'ui/mpris_specs/{spec}',
)
with open(spec_path) as f:
node_info = Gio.DBusNodeInfo.new_for_xml(f.read())
connection.register_object(
'/org/mpris/MediaPlayer2',
node_info.interfaces[0],
self.on_method_call,
self.on_get_property,
self.on_set_property,
)
# TODO (#127): I have no idea what to do here.
def dbus_name_lost(*args):
pass
self.bus_number = Gio.bus_own_name_on_connection(
connection,
'org.mpris.MediaPlayer2.sublimemusic',
Gio.BusNameOwnerFlags.NONE,
dbus_name_acquired,
dbus_name_lost,
)
def shutdown(self):
Gio.bus_unown_name(self.bus_number)
def on_get_property(
self,
connection: Gio.DBusConnection,
sender: str,
path: str,
interface: str,
property_name: str,
) -> GLib.Variant:
value = self.property_dict().get(interface, {}).get(property_name)
return DBusManager.to_variant(value)
def on_method_call(
self,
connection: Gio.DBusConnection,
sender: str,
path: str,
interface: str,
method: str,
params: GLib.Variant,
invocation: Gio.DBusMethodInvocation,
):
if not CacheManager.ready():
return
# TODO (#127): I don't even know if this works.
if interface == 'org.freedesktop.DBus.Properties':
if method == 'Get':
invocation.return_value(
self.on_get_property(
connection, sender, path, interface, *params))
elif method == 'Set':
self.on_set_property(
connection, sender, path, interface, *params)
elif method == 'GetAll':
all_properties = {
k: DBusManager.to_variant(v)
for k, v in self.property_dict()[interface].items()
}
invocation.return_value(
GLib.Variant('(a{sv})', (all_properties, )))
return
self.do_on_method_call(
connection,
sender,
path,
interface,
method,
params,
invocation,
)
@staticmethod
def to_variant(value: Any) -> GLib.Variant:
if callable(value):
return DBusManager.to_variant(value())
if isinstance(value, GLib.Variant):
return value
if type(value) == tuple:
return GLib.Variant(*value)
if type(value) == dict:
return GLib.Variant(
'a{sv}',
{k: DBusManager.to_variant(v)
for k, v in value.items()},
)
variant_type = {
list: 'as',
str: 's',
int: 'i',
float: 'd',
bool: 'b',
}.get(type(value))
if not variant_type:
return value
return GLib.Variant(variant_type, value)
def property_dict(self) -> Dict[str, Any]:
if not CacheManager.ready():
return {}
state, player = self.get_state_and_player()
has_current_song = state.current_song is not None
has_next_song = False
if state.repeat_type in (RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG):
has_next_song = True
elif has_current_song:
has_next_song = (
state.current_song_index < len(state.play_queue) - 1)
if state.active_playlist_id is None:
active_playlist = (False, GLib.Variant('(oss)', ('/', '', '')))
else:
playlist_result = CacheManager.get_playlist(
state.active_playlist_id)
if playlist_result.is_future:
# If we have to wait for the playlist result, just return
# no playlist.
active_playlist = (False, GLib.Variant('(oss)', ('/', '', '')))
else:
playlist = playlist_result.result()
active_playlist = (
True,
GLib.Variant(
'(oss)',
(
'/playlist/' + playlist.id,
playlist.name,
CacheManager.get_cover_art_url(playlist.coverArt),
),
),
)
get_playlists_result = CacheManager.get_playlists()
if get_playlists_result.is_future:
playlist_count = 0
else:
playlist_count = len(get_playlists_result.result())
return {
'org.mpris.MediaPlayer2': {
'CanQuit': True,
'CanRaise': True,
'HasTrackList': True,
'Identity': 'Sublime Music',
'DesktopEntry': 'sublime-music',
'SupportedUriSchemes': [],
'SupportedMimeTypes': [],
},
'org.mpris.MediaPlayer2.Player': {
'PlaybackStatus': {
(False, False): 'Stopped',
(False, True): 'Stopped',
(True, False): 'Paused',
(True, True): 'Playing',
}[player is not None and player.song_loaded, state.playing],
'LoopStatus':
state.repeat_type.as_mpris_loop_status(),
'Rate':
1.0,
'Shuffle':
state.shuffle_on,
'Metadata':
self.get_mpris_metadata(
state.current_song_index,
state.play_queue,
) if state.current_song else {},
'Volume':
0.0 if state.is_muted else state.volume / 100,
'Position': (
'x',
int(
max(state.song_progress or 0, 0)
* self.second_microsecond_conversion),
),
'MinimumRate':
1.0,
'MaximumRate':
1.0,
'CanGoNext':
has_current_song and has_next_song,
'CanGoPrevious':
has_current_song,
'CanPlay':
True,
'CanPause':
True,
'CanSeek':
True,
'CanControl':
True,
},
'org.mpris.MediaPlayer2.TrackList': {
'Tracks': self.get_dbus_playlist(state.play_queue),
'CanEditTracks': False,
},
'org.mpris.MediaPlayer2.Playlists': {
'PlaylistCount': playlist_count,
'Orderings': ['Alphabetical', 'Created', 'Modified'],
'ActivePlaylist': ('(b(oss))', active_playlist),
},
}
def get_mpris_metadata(
self,
idx: int,
play_queue: List[str],
) -> Dict[str, Any]:
song_result = CacheManager.get_song_details(play_queue[idx])
if song_result.is_future:
return {}
song = song_result.result()
trackid = self.get_dbus_playlist(play_queue)[idx]
duration = (
'x',
(song.duration or 0) * self.second_microsecond_conversion,
)
return {
'mpris:trackid': trackid,
'mpris:length': duration,
'mpris:artUrl': CacheManager.get_cover_art_url(song.coverArt),
'xesam:album': song.album or '',
'xesam:albumArtist': [song.artist or ''],
'xesam:artist': [song.artist or ''],
'xesam:title': song.title,
}
def get_dbus_playlist(self, play_queue: List[str]) -> List[str]:
seen_counts: DefaultDict[str, int] = defaultdict(int)
tracks = []
for song_id in play_queue:
id_ = seen_counts[song_id]
tracks.append(f'/song/{song_id}/{id_}')
seen_counts[song_id] += 1
return tracks
diff_parse_re = re.compile(r"root\['(.*?)'\]\['(.*?)'\](?:\[.*\])?")
def property_diff(self):
new_property_dict = self.property_dict()
diff = DeepDiff(self.current_state, new_property_dict)
changes = defaultdict(dict)
for path, change in diff.get('values_changed', {}).items():
interface, property_name = self.diff_parse_re.match(path).groups()
changes[interface][property_name] = change['new_value']
if diff.get('dictionary_item_added'):
changes = new_property_dict
for interface, changed_props in changes.items():
# If the metadata has changed, just make the entire Metadata object
# part of the update.
if 'Metadata' in changed_props.keys():
changed_props['Metadata'] = new_property_dict[interface][
'Metadata']
# Special handling for when the position changes (a seek).
# Technically, I'm sending this signal too often, but I don't think
# it really matters.
if (interface == 'org.mpris.MediaPlayer2.Player'
and 'Position' in changed_props):
self.connection.emit_signal(
None,
'/org/mpris/MediaPlayer2',
interface,
'Seeked',
GLib.Variant('(x)', (changed_props['Position'][1], )),
)
# Do not emit the property change.
del changed_props['Position']
# Special handling for when the track list changes.
# Technically, I'm supposed to use `TrackAdded` and `TrackRemoved`
# signals when minor changes occur, but the docs also say that:
#
# > It is left up to the implementation to decide when a change to
# > the track list is invasive enough that this signal should be
# > emitted instead of a series of TrackAdded and TrackRemoved
# > signals.
#
# So I think that any change is invasive enough that I should use
# this signal.
if (interface == 'org.mpris.MediaPlayer2.TrackList'
and 'Tracks' in changed_props):
track_list = changed_props['Tracks']
if len(track_list) > 0:
current_track = (
new_property_dict['org.mpris.MediaPlayer2.Player']
['Metadata'].get('mpris:trackid', track_list[0]))
self.connection.emit_signal(
None,
'/org/mpris/MediaPlayer2',
interface,
'TrackListReplaced',
GLib.Variant('(aoo)', (track_list, current_track)),
)
self.connection.emit_signal(
None,
'/org/mpris/MediaPlayer2',
'org.freedesktop.DBus.Properties',
'PropertiesChanged',
GLib.Variant(
'(sa{sv}as)', (
interface,
{
k: DBusManager.to_variant(v)
for k, v in changed_props.items()
},
[],
)),
)
# Update state for next diff.
self.current_state = new_property_dict

View File

@@ -1,80 +0,0 @@
import typing
from datetime import datetime
from enum import EnumMeta
from typing import Any, Dict, Type
from dateutil import parser
def from_json(template_type: Any, data: Any) -> Any:
"""
Converts data from a JSON parse into an instantiation of the Python object
specified by template_type.
Arguments:
template_type: the template type to deserialize into
data: the data to deserialize to the class
"""
# Approach for deserialization here:
# https://stackoverflow.com/a/40639688/2319844
# If it's a forward reference, evaluate it to figure out the actual
# type. This allows for types that have to be put into a string.
if isinstance(template_type, typing.ForwardRef): # type: ignore
template_type = template_type._evaluate(globals(), locals())
annotations: Dict[str,
Type] = getattr(template_type, '__annotations__', {})
# Handle primitive of objects
instance: Any = None
if data is None:
instance = None
# Handle generics. List[*], Dict[*, *] in particular.
elif type(template_type) == typing._GenericAlias: # type: ignore
# Having to use this because things changed in Python 3.7.
class_name = template_type._name
# This is not very elegant since it doesn't allow things which sublass
# from List or Dict. For my purposes, this doesn't matter.
if class_name == 'List':
inner_type = template_type.__args__[0]
instance = [from_json(inner_type, value) for value in data]
elif class_name == 'Dict':
key_type, val_type = template_type.__args__
instance = {
from_json(key_type, key): from_json(val_type, value)
for key, value in data.items()
}
else:
raise Exception(
'Trying to deserialize an unsupported type: {}'.format(
template_type._name))
elif template_type == str or issubclass(template_type, str):
instance = data
elif template_type == int or issubclass(template_type, int):
instance = int(data)
elif template_type == bool or issubclass(template_type, bool):
instance = bool(data)
elif type(template_type) == EnumMeta:
if type(data) == dict:
instance = template_type(data.get('_value_'))
else:
instance = template_type(data)
elif template_type == datetime:
if type(data) == int:
instance = datetime.fromtimestamp(data / 1000)
else:
instance = parser.parse(data)
# Handle everything else by first instantiating the class, then adding
# all of the sub-elements, recursively calling from_json on them.
else:
instance = template_type()
for field, field_type in annotations.items():
value = data.get(field)
setattr(instance, field, from_json(field_type, value))
return instance

View File

@@ -1,3 +1,4 @@
import abc
import base64 import base64
import io import io
import logging import logging
@@ -6,6 +7,9 @@ import os
import socket import socket
import threading import threading
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
from time import sleep from time import sleep
from typing import Any, Callable, List, Optional from typing import Any, Callable, List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -15,23 +19,30 @@ import bottle
import mpv import mpv
import pychromecast import pychromecast
from sublime.cache_manager import CacheManager from sublime.adapters import AdapterManager
from sublime.adapters.api_objects import Song
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.server.api_objects import Child
@dataclass
class PlayerEvent: class PlayerEvent:
name: str class Type(Enum):
value: Any PLAY_STATE_CHANGE = 0
VOLUME_CHANGE = 1
STREAM_CACHE_PROGRESS_CHANGE = 2
def __init__(self, name: str, value: Any): type: Type
self.name = name playing: Optional[bool] = False
self.value = value volume: Optional[float] = 0.0
stream_cache_duration: Optional[float] = 0.0
class Player: 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 _can_hotswap_source: bool
# TODO (#205): unify on_timepos_change and on_player_event?
def __init__( def __init__(
self, self,
on_timepos_change: Callable[[Optional[float]], None], on_timepos_change: Callable[[Optional[float]], None],
@@ -74,52 +85,58 @@ class Player:
self._set_is_muted(value) self._set_is_muted(value)
def reset(self): def reset(self):
raise NotImplementedError( raise NotImplementedError("reset must be implemented by implementor of Player")
'reset must be implemented by implementor of Player')
def play_media(self, file_or_url: str, progress: float, song: Child): def play_media(self, file_or_url: str, progress: timedelta, song: Song):
raise NotImplementedError( raise NotImplementedError(
'play_media must be implemented by implementor of Player') "play_media must be implemented by implementor of Player"
)
def _is_playing(self): def _is_playing(self):
raise NotImplementedError( raise NotImplementedError(
'_is_playing must be implemented by implementor of Player') "_is_playing must be implemented by implementor of Player"
)
def pause(self): def pause(self):
raise NotImplementedError( raise NotImplementedError("pause must be implemented by implementor of Player")
'pause must be implemented by implementor of Player')
def toggle_play(self): def toggle_play(self):
raise NotImplementedError( raise NotImplementedError(
'toggle_play must be implemented by implementor of Player') "toggle_play must be implemented by implementor of Player"
)
def seek(self, value: float): def seek(self, value: timedelta):
raise NotImplementedError( raise NotImplementedError("seek must be implemented by implementor of Player")
'seek must be implemented by implementor of Player')
def _get_timepos(self): def _get_timepos(self):
raise NotImplementedError( raise NotImplementedError(
'get_timepos must be implemented by implementor of Player') "get_timepos must be implemented by implementor of Player"
)
def _get_volume(self): def _get_volume(self):
raise NotImplementedError( raise NotImplementedError(
'_get_volume must be implemented by implementor of Player') "_get_volume must be implemented by implementor of Player"
)
def _set_volume(self, value: float): def _set_volume(self, value: float):
raise NotImplementedError( raise NotImplementedError(
'_set_volume must be implemented by implementor of Player') "_set_volume must be implemented by implementor of Player"
)
def _get_is_muted(self): def _get_is_muted(self):
raise NotImplementedError( raise NotImplementedError(
'_get_is_muted must be implemented by implementor of Player') "_get_is_muted must be implemented by implementor of Player"
)
def _set_is_muted(self, value: bool): def _set_is_muted(self, value: bool):
raise NotImplementedError( raise NotImplementedError(
'_set_is_muted must be implemented by implementor of Player') "_set_is_muted must be implemented by implementor of Player"
)
def shutdown(self): def shutdown(self):
raise NotImplementedError( raise NotImplementedError(
'shutdown must be implemented by implementor of Player') "shutdown must be implemented by implementor of Player"
)
class MPVPlayer(Player): class MPVPlayer(Player):
@@ -130,20 +147,19 @@ class MPVPlayer(Player):
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
config: AppConfiguration, config: AppConfiguration,
): ):
super().__init__( super().__init__(on_timepos_change, on_track_end, on_player_event, config)
on_timepos_change, on_track_end, on_player_event, config)
self.mpv = mpv.MPV() self.mpv = mpv.MPV()
self.mpv.audio_client_name = 'sublime-music' self.mpv.audio_client_name = "sublime-music"
self.mpv.replaygain = config.replay_gain.as_string() self.mpv.replaygain = config.replay_gain.as_string()
self.progress_value_lock = threading.Lock() self.progress_value_lock = threading.Lock()
self.progress_value_count = 0 self.progress_value_count = 0
self._muted = False self._muted = False
self._volume = 100. self._volume = 100.0
self._can_hotswap_source = True self._can_hotswap_source = True
@self.mpv.property_observer('time-pos') @self.mpv.property_observer("time-pos")
def time_observer(_: Any, value: Optional[float]): def time_observer(_, value: Optional[float]):
self.on_timepos_change(value) self.on_timepos_change(value)
if value is None and self.progress_value_count > 1: if value is None and self.progress_value_count > 1:
self.on_track_end() self.on_track_end()
@@ -154,6 +170,15 @@ class MPVPlayer(Player):
with self.progress_value_lock: with self.progress_value_lock:
self.progress_value_count += 1 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: def _is_playing(self) -> bool:
return not self.mpv.pause return not self.mpv.pause
@@ -162,17 +187,17 @@ class MPVPlayer(Player):
with self.progress_value_lock: with self.progress_value_lock:
self.progress_value_count = 0 self.progress_value_count = 0
def play_media(self, file_or_url: str, progress: float, song: Child): def play_media(self, file_or_url: str, progress: timedelta, song: Song):
self.had_progress_value = False self.had_progress_value = False
with self.progress_value_lock: with self.progress_value_lock:
self.progress_value_count = 0 self.progress_value_count = 0
self.mpv.pause = False self.mpv.pause = False
self.mpv.command( self.mpv.command(
'loadfile', "loadfile",
file_or_url, file_or_url,
'replace', "replace",
f'start={progress}' if progress else '', f"force-seekable=yes,start={progress.total_seconds()}" if progress else "",
) )
self._song_loaded = True self._song_loaded = True
@@ -180,10 +205,10 @@ class MPVPlayer(Player):
self.mpv.pause = True self.mpv.pause = True
def toggle_play(self): def toggle_play(self):
self.mpv.cycle('pause') self.mpv.cycle("pause")
def seek(self, value: float): def seek(self, value: timedelta):
self.mpv.seek(str(value), 'absolute') self.mpv.seek(str(value.total_seconds()), "absolute")
def _get_volume(self) -> float: def _get_volume(self) -> float:
return self._volume return self._volume
@@ -236,31 +261,36 @@ class ChromecastPlayer(Player):
self.app = bottle.Bottle() self.app = bottle.Bottle()
@self.app.route('/') @self.app.route("/")
def index() -> str: def index() -> str:
return ''' return """
<h1>Sublime Music Local Music Server</h1> <h1>Sublime Music Local Music Server</h1>
<p> <p>
Sublime Music uses this port as a server for serving music Sublime Music uses this port as a server for serving music
Chromecasts on the same LAN. Chromecasts on the same LAN.
</p> </p>
''' """
@self.app.route('/s/<token>') @self.app.route("/s/<token>")
def stream_song(token: str) -> bytes: def stream_song(token: str) -> bytes:
if token != self.token: assert self.song_id
raise bottle.HTTPError(status=401, body='Invalid token.')
song = CacheManager.get_song_details(self.song_id).result() if token != self.token:
filename, _ = CacheManager.get_song_filename_or_stream(song) raise bottle.HTTPError(status=401, body="Invalid token.")
with open(filename, 'rb') as fin:
# 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()) song_buffer = io.BytesIO(fin.read())
bottle.response.set_header( bottle.response.set_header(
'Content-Type', "Content-Type", mimetypes.guess_type(filename)[0],
mimetypes.guess_type(filename)[0],
) )
bottle.response.set_header('Accept-Ranges', 'bytes') bottle.response.set_header("Accept-Ranges", "bytes")
return song_buffer.read() return song_buffer.read()
def set_song_and_token(self, song_id: str, token: str): def set_song_and_token(self, song_id: str, token: str):
@@ -276,11 +306,11 @@ class ChromecastPlayer(Player):
def get_chromecasts(cls) -> Future: def get_chromecasts(cls) -> Future:
def do_get_chromecasts() -> List[pychromecast.Chromecast]: def do_get_chromecasts() -> List[pychromecast.Chromecast]:
if not ChromecastPlayer.getting_chromecasts: if not ChromecastPlayer.getting_chromecasts:
logging.info('Getting Chromecasts') logging.info("Getting Chromecasts")
ChromecastPlayer.getting_chromecasts = True ChromecastPlayer.getting_chromecasts = True
ChromecastPlayer.chromecasts = pychromecast.get_chromecasts() ChromecastPlayer.chromecasts = pychromecast.get_chromecasts()
else: else:
logging.info('Already getting Chromecasts... busy wait') logging.info("Already getting Chromecasts... busy wait")
while ChromecastPlayer.getting_chromecasts: while ChromecastPlayer.getting_chromecasts:
sleep(0.1) sleep(0.1)
@@ -291,15 +321,15 @@ class ChromecastPlayer(Player):
def set_playing_chromecast(self, uuid: str): def set_playing_chromecast(self, uuid: str):
self.chromecast = next( self.chromecast = next(
cc for cc in ChromecastPlayer.chromecasts cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid)
if cc.device.uuid == UUID(uuid)) )
self.chromecast.media_controller.register_status_listener( self.chromecast.media_controller.register_status_listener(
ChromecastPlayer.media_status_listener) ChromecastPlayer.media_status_listener
self.chromecast.register_status_listener( )
ChromecastPlayer.cast_status_listener) self.chromecast.register_status_listener(ChromecastPlayer.cast_status_listener)
self.chromecast.wait() self.chromecast.wait()
logging.info(f'Using: {self.chromecast.device.friendly_name}') logging.info(f"Using: {self.chromecast.device.friendly_name}")
def __init__( def __init__(
self, self,
@@ -308,16 +338,17 @@ class ChromecastPlayer(Player):
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
config: AppConfiguration, config: AppConfiguration,
): ):
super().__init__( super().__init__(on_timepos_change, on_track_end, on_player_event, config)
on_timepos_change, on_track_end, on_player_event, config)
self._timepos = 0.0 self._timepos = 0.0
self.time_incrementor_running = False self.time_incrementor_running = False
self._can_hotswap_source = False self._can_hotswap_source = False
ChromecastPlayer.cast_status_listener.on_new_cast_status = ( ChromecastPlayer.cast_status_listener.on_new_cast_status = (
self.on_new_cast_status) self.on_new_cast_status
)
ChromecastPlayer.media_status_listener.on_new_media_status = ( ChromecastPlayer.media_status_listener.on_new_media_status = (
self.on_new_media_status) self.on_new_media_status
)
# Set host_ip # Set host_ip
# TODO (#128): should have a mechanism to update this. Maybe it should # TODO (#128): should have a mechanism to update this. Maybe it should
@@ -326,7 +357,7 @@ class ChromecastPlayer(Player):
# piped over the VPN tunnel. # piped over the VPN tunnel.
try: try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80)) s.connect(("8.8.8.8", 80))
self.host_ip = s.getsockname()[0] self.host_ip = s.getsockname()[0]
s.close() s.close()
except OSError: except OSError:
@@ -336,42 +367,46 @@ class ChromecastPlayer(Player):
self.serve_over_lan = config.serve_over_lan self.serve_over_lan = config.serve_over_lan
if self.serve_over_lan: if self.serve_over_lan:
self.server_thread = ChromecastPlayer.ServerThread( self.server_thread = ChromecastPlayer.ServerThread("0.0.0.0", self.port)
'0.0.0.0', self.port)
self.server_thread.start() self.server_thread.start()
def on_new_cast_status( def on_new_cast_status(
self, self, status: pychromecast.socket_client.CastStatus,
status: pychromecast.socket_client.CastStatus,
): ):
self.on_player_event( self.on_player_event(
PlayerEvent( PlayerEvent(
'volume_change', PlayerEvent.Type.VOLUME_CHANGE,
status.volume_level * 100 if not status.volume_muted else 0, volume=(status.volume_level * 100 if not status.volume_muted else 0),
)) )
)
# This normally happens when "Stop Casting" is pressed in the Google # This normally happens when "Stop Casting" is pressed in the Google
# Home app. # Home app.
if status.session_id is None: if status.session_id is None:
self.on_player_event(PlayerEvent('play_state_change', False)) self.on_player_event(
PlayerEvent(PlayerEvent.Type.PLAY_STATE_CHANGE, playing=False)
)
self._song_loaded = False self._song_loaded = False
def on_new_media_status( def on_new_media_status(
self, self, status: pychromecast.controllers.media.MediaStatus,
status: pychromecast.controllers.media.MediaStatus,
): ):
# Detect the end of a track and go to the next one. # Detect the end of a track and go to the next one.
if (status.idle_reason == 'FINISHED' and status.player_state == 'IDLE' if (
and self._timepos > 0): status.idle_reason == "FINISHED"
and status.player_state == "IDLE"
and self._timepos > 0
):
self.on_track_end() self.on_track_end()
self._timepos = status.current_time self._timepos = status.current_time
self.on_player_event( self.on_player_event(
PlayerEvent( PlayerEvent(
'play_state_change', PlayerEvent.Type.PLAY_STATE_CHANGE,
status.player_state in ('PLAYING', 'BUFFERING'), playing=(status.player_state in ("PLAYING", "BUFFERING")),
)) )
)
# Start the time incrementor just in case this was a play notification. # Start the time incrementor just in case this was a play notification.
self.start_time_incrementor() self.start_time_incrementor()
@@ -400,8 +435,7 @@ class ChromecastPlayer(Player):
if self.playing: if self.playing:
break break
if url is not None: if url is not None:
if (url == self.chromecast.media_controller.status if url == self.chromecast.media_controller.status.content_id:
.content_id):
break break
callback() callback()
@@ -416,40 +450,40 @@ class ChromecastPlayer(Player):
def reset(self): def reset(self):
self._song_loaded = False self._song_loaded = False
def play_media(self, file_or_url: str, progress: float, song: Child): def play_media(self, file_or_url: str, progress: timedelta, song: Song):
stream_scheme = urlparse(file_or_url).scheme stream_scheme = urlparse(file_or_url).scheme
# If it's a local file, then see if we can serve it over the LAN. # If it's a local file, then see if we can serve it over the LAN.
if not stream_scheme: if not stream_scheme:
if self.serve_over_lan: if self.serve_over_lan:
token = base64.b64encode(os.urandom(64)).decode('ascii') token = base64.b64encode(os.urandom(64)).decode("ascii")
for r in (('+', '.'), ('/', '-'), ('=', '_')): for r in (("+", "."), ("/", "-"), ("=", "_")):
token = token.replace(*r) token = token.replace(*r)
self.server_thread.set_song_and_token(song.id, token) self.server_thread.set_song_and_token(song.id, token)
file_or_url = f'http://{self.host_ip}:{self.port}/s/{token}' file_or_url = f"http://{self.host_ip}:{self.port}/s/{token}"
else: else:
file_or_url, _ = CacheManager.get_song_filename_or_stream( file_or_url = AdapterManager.get_song_filename_or_stream(
song, song, force_stream=True,
force_stream=True,
) )
cover_art_url = CacheManager.get_cover_art_url(song.coverArt) cover_art_url = AdapterManager.get_cover_art_uri(song.cover_art, size=1000)
self.chromecast.media_controller.play_media( self.chromecast.media_controller.play_media(
file_or_url, file_or_url,
# Just pretend that whatever we send it is mp3, even if it isn't. # Just pretend that whatever we send it is mp3, even if it isn't.
'audio/mp3', "audio/mp3",
current_time=progress, current_time=progress.total_seconds(),
title=song.title, title=song.title,
thumb=cover_art_url, thumb=cover_art_url,
metadata={ metadata={
'metadataType': 3, "metadataType": 3,
'albumName': song.album, "albumName": song.album.name if song.album else None,
'artist': song.artist, "artist": song.artist.name if song.artist else None,
'trackNumber': song.track, "trackNumber": song.track,
}, },
) )
self._timepos = progress self._timepos = progress.total_seconds()
def on_play_begin(): def on_play_begin():
# TODO (#206) this starts too soon, do something better
self._song_loaded = True self._song_loaded = True
self.start_time_incrementor() self.start_time_incrementor()
@@ -466,9 +500,9 @@ class ChromecastPlayer(Player):
self.chromecast.media_controller.play() self.chromecast.media_controller.play()
self.wait_for_playing(self.start_time_incrementor) self.wait_for_playing(self.start_time_incrementor)
def seek(self, value: float): def seek(self, value: timedelta):
do_pause = not self.playing do_pause = not self.playing
self.chromecast.media_controller.seek(value) self.chromecast.media_controller.seek(value.total_seconds())
if do_pause: if do_pause:
self.pause() self.pause()

View File

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

View File

@@ -1,47 +0,0 @@
from enum import Enum
from typing import Any, Dict
from sublime.from_json import from_json as _from_json
class APIObject:
"""
Defines the base class for objects coming from the Subsonic API. For now,
this only supports JSON.
"""
@classmethod
def from_json(cls, data: Dict[str, Any]) -> Any:
"""
Creates an :class:`APIObject` by deserializing JSON data into a Python
object. This calls the :class:`sublime.from_json.from_json` function
to do the deserializing.
:param data: a Python dictionary representation of the data to
deserialize
"""
return _from_json(cls, data)
def get(self, field: str, default: Any = None) -> Any:
"""
Get the value of ``field`` or ``default``.
:param field: name of the field to retrieve
:param default: the default value to return if ``field`` is falsy.
"""
return getattr(self, field, default)
def __repr__(self) -> str:
if isinstance(self, Enum):
return super().__repr__()
if isinstance(self, str):
return self
annotations: Dict[str, Any] = self.get('__annotations__', {})
typename = type(self).__name__
fieldstr = ' '.join(
[
f'{field}={getattr(self, field)!r}'
for field in annotations.keys()
if hasattr(self, field) and getattr(self, field) is not None
])
return f'<{typename} {fieldstr}>'

View File

@@ -1,789 +0,0 @@
"""
WARNING: AUTOGENERATED FILE
This file was generated by the api_object_generator.py
script. Do not modify this file directly, rather modify the
script or run it on a new API version.
"""
from datetime import datetime
from enum import Enum
from typing import Any, List
from sublime.server.api_object import APIObject
class AlbumInfo(APIObject):
notes: List[str]
musicBrainzId: List[str]
lastFmUrl: List[str]
smallImageUrl: List[str]
mediumImageUrl: List[str]
largeImageUrl: List[str]
value: str
class AverageRating(APIObject, float):
pass
class MediaType(APIObject, Enum):
MUSIC = 'music'
PODCAST = 'podcast'
AUDIOBOOK = 'audiobook'
VIDEO = 'video'
class UserRating(APIObject, int):
pass
class Child(APIObject):
id: str
value: str
parent: str
isDir: bool
title: str
album: str
artist: str
track: int
year: int
genre: str
coverArt: str
size: int
contentType: str
suffix: str
transcodedContentType: str
transcodedSuffix: str
duration: int
bitRate: int
path: str
isVideo: bool
userRating: UserRating
averageRating: AverageRating
playCount: int
discNumber: int
created: datetime
starred: datetime
albumId: str
artistId: str
type: MediaType
bookmarkPosition: int
originalWidth: int
originalHeight: int
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Child.{self.id}')
class AlbumList(APIObject):
album: List[Child]
value: str
class AlbumID3(APIObject):
id: str
value: str
name: str
artist: str
artistId: str
coverArt: str
songCount: int
duration: int
playCount: int
created: datetime
starred: datetime
year: int
genre: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'AlbumID3.{self.id}')
class AlbumList2(APIObject):
album: List[AlbumID3]
value: str
class AlbumWithSongsID3(APIObject):
song: List[Child]
value: str
id: str
name: str
artist: str
artistId: str
coverArt: str
songCount: int
duration: int
playCount: int
created: datetime
starred: datetime
year: int
genre: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'AlbumID3.{self.id}')
class Artist(APIObject):
id: str
value: str
name: str
artistImageUrl: str
starred: datetime
userRating: UserRating
averageRating: AverageRating
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Artist.{self.id}')
class ArtistInfoBase(APIObject):
biography: List[str]
musicBrainzId: List[str]
lastFmUrl: List[str]
smallImageUrl: List[str]
mediumImageUrl: List[str]
largeImageUrl: List[str]
value: str
class ArtistInfo(APIObject):
similarArtist: List[Artist]
value: str
biography: List[str]
musicBrainzId: List[str]
lastFmUrl: List[str]
smallImageUrl: List[str]
mediumImageUrl: List[str]
largeImageUrl: List[str]
class ArtistID3(APIObject):
id: str
value: str
name: str
coverArt: str
artistImageUrl: str
albumCount: int
starred: datetime
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'ArtistID3.{self.id}')
class ArtistInfo2(APIObject):
similarArtist: List[ArtistID3]
value: str
biography: List[str]
musicBrainzId: List[str]
lastFmUrl: List[str]
smallImageUrl: List[str]
mediumImageUrl: List[str]
largeImageUrl: List[str]
class ArtistWithAlbumsID3(APIObject):
album: List[AlbumID3]
value: str
id: str
name: str
coverArt: str
artistImageUrl: str
albumCount: int
starred: datetime
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'ArtistID3.{self.id}')
class IndexID3(APIObject):
artist: List[ArtistID3]
value: str
name: str
class ArtistsID3(APIObject):
index: List[IndexID3]
value: str
ignoredArticles: str
class Bookmark(APIObject):
entry: List[Child]
value: str
position: int
username: str
comment: str
created: datetime
changed: datetime
class Bookmarks(APIObject):
bookmark: List[Bookmark]
value: str
class ChatMessage(APIObject):
username: str
value: str
time: int
message: str
class ChatMessages(APIObject):
chatMessage: List[ChatMessage]
value: str
class Directory(APIObject):
child: List[Child]
value: str
id: str
parent: str
name: str
starred: datetime
userRating: UserRating
averageRating: AverageRating
playCount: int
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Directory.{self.id}')
class Error(APIObject):
code: int
value: str
message: str
class Genre(APIObject):
songCount: int
value: str
albumCount: int
class Genres(APIObject):
genre: List[Genre]
value: str
class Index(APIObject):
artist: List[Artist]
value: str
name: str
class Indexes(APIObject):
shortcut: List[Artist]
index: List[Index]
child: List[Child]
value: str
lastModified: int
ignoredArticles: str
class InternetRadioStation(APIObject):
id: str
value: str
name: str
streamUrl: str
homePageUrl: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'InternetRadioStation.{self.id}')
class InternetRadioStations(APIObject):
internetRadioStation: List[InternetRadioStation]
value: str
class JukeboxStatus(APIObject):
currentIndex: int
value: str
playing: bool
gain: float
position: int
class JukeboxPlaylist(APIObject):
entry: List[Child]
value: str
currentIndex: int
playing: bool
gain: float
position: int
class License(APIObject):
valid: bool
value: str
email: str
licenseExpires: datetime
trialExpires: datetime
class Lyrics(APIObject):
artist: str
value: str
title: str
class MusicFolder(APIObject):
id: int
value: str
name: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'MusicFolder.{self.id}')
class MusicFolders(APIObject):
musicFolder: List[MusicFolder]
value: str
class PodcastStatus(APIObject, Enum):
NEW = 'new'
DOWNLOADING = 'downloading'
COMPLETED = 'completed'
ERROR = 'error'
DELETED = 'deleted'
SKIPPED = 'skipped'
class PodcastEpisode(APIObject):
streamId: str
channelId: str
description: str
status: PodcastStatus
publishDate: datetime
value: str
id: str
parent: str
isDir: bool
title: str
album: str
artist: str
track: int
year: int
genre: str
coverArt: str
size: int
contentType: str
suffix: str
transcodedContentType: str
transcodedSuffix: str
duration: int
bitRate: int
path: str
isVideo: bool
userRating: UserRating
averageRating: AverageRating
playCount: int
discNumber: int
created: datetime
starred: datetime
albumId: str
artistId: str
type: MediaType
bookmarkPosition: int
originalWidth: int
originalHeight: int
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Child.{self.id}')
class NewestPodcasts(APIObject):
episode: List[PodcastEpisode]
value: str
class NowPlayingEntry(APIObject):
username: str
minutesAgo: int
playerId: int
playerName: str
value: str
id: str
parent: str
isDir: bool
title: str
album: str
artist: str
track: int
year: int
genre: str
coverArt: str
size: int
contentType: str
suffix: str
transcodedContentType: str
transcodedSuffix: str
duration: int
bitRate: int
path: str
isVideo: bool
userRating: UserRating
averageRating: AverageRating
playCount: int
discNumber: int
created: datetime
starred: datetime
albumId: str
artistId: str
type: MediaType
bookmarkPosition: int
originalWidth: int
originalHeight: int
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Child.{self.id}')
class NowPlaying(APIObject):
entry: List[NowPlayingEntry]
value: str
class PlayQueue(APIObject):
entry: List[Child]
value: str
current: int
position: int
username: str
changed: datetime
changedBy: str
class Playlist(APIObject):
allowedUser: List[str]
value: str
id: str
name: str
comment: str
owner: str
public: bool
songCount: int
duration: int
created: datetime
changed: datetime
coverArt: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Playlist.{self.id}')
class PlaylistWithSongs(APIObject):
entry: List[Child]
value: str
allowedUser: List[str]
id: str
name: str
comment: str
owner: str
public: bool
songCount: int
duration: int
created: datetime
changed: datetime
coverArt: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Playlist.{self.id}')
class Playlists(APIObject):
playlist: List[Playlist]
value: str
class PodcastChannel(APIObject):
episode: List[PodcastEpisode]
value: str
id: str
url: str
title: str
description: str
coverArt: str
originalImageUrl: str
status: PodcastStatus
errorMessage: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'PodcastChannel.{self.id}')
class Podcasts(APIObject):
channel: List[PodcastChannel]
value: str
class ResponseStatus(APIObject, Enum):
OK = 'ok'
FAILED = 'failed'
class ScanStatus(APIObject):
scanning: bool
value: str
count: int
class SearchResult(APIObject):
match: List[Child]
value: str
offset: int
totalHits: int
class SearchResult2(APIObject):
artist: List[Artist]
album: List[Child]
song: List[Child]
value: str
class SearchResult3(APIObject):
artist: List[ArtistID3]
album: List[AlbumID3]
song: List[Child]
value: str
class Share(APIObject):
entry: List[Child]
value: str
id: str
url: str
description: str
username: str
created: datetime
expires: datetime
lastVisited: datetime
visitCount: int
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Share.{self.id}')
class Shares(APIObject):
share: List[Share]
value: str
class SimilarSongs(APIObject):
song: List[Child]
value: str
class SimilarSongs2(APIObject):
song: List[Child]
value: str
class Songs(APIObject):
song: List[Child]
value: str
class Starred(APIObject):
artist: List[Artist]
album: List[Child]
song: List[Child]
value: str
class Starred2(APIObject):
artist: List[ArtistID3]
album: List[AlbumID3]
song: List[Child]
value: str
class TopSongs(APIObject):
song: List[Child]
value: str
class User(APIObject):
folder: List[int]
value: str
username: str
email: str
scrobblingEnabled: bool
maxBitRate: int
adminRole: bool
settingsRole: bool
downloadRole: bool
uploadRole: bool
playlistRole: bool
coverArtRole: bool
commentRole: bool
podcastRole: bool
streamRole: bool
jukeboxRole: bool
shareRole: bool
videoConversionRole: bool
avatarLastChanged: datetime
class Users(APIObject):
user: List[User]
value: str
class Version(APIObject, str):
pass
class AudioTrack(APIObject):
id: str
value: str
name: str
languageCode: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'AudioTrack.{self.id}')
class Captions(APIObject):
id: str
value: str
name: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'Captions.{self.id}')
class VideoConversion(APIObject):
id: str
value: str
bitRate: int
audioTrackId: int
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'VideoConversion.{self.id}')
class VideoInfo(APIObject):
captions: List[Captions]
audioTrack: List[AudioTrack]
conversion: List[VideoConversion]
value: str
id: str
def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other)
def __hash__(self) -> int:
return hash(f'VideoInfo.{self.id}')
class Videos(APIObject):
video: List[Child]
value: str
class Response(APIObject):
musicFolders: MusicFolders
indexes: Indexes
directory: Directory
genres: Genres
artists: ArtistsID3
artist: ArtistWithAlbumsID3
album: AlbumWithSongsID3
song: Child
videos: Videos
videoInfo: VideoInfo
nowPlaying: NowPlaying
searchResult: SearchResult
searchResult2: SearchResult2
searchResult3: SearchResult3
playlists: Playlists
playlist: PlaylistWithSongs
jukeboxStatus: JukeboxStatus
jukeboxPlaylist: JukeboxPlaylist
license: License
users: Users
user: User
chatMessages: ChatMessages
albumList: AlbumList
albumList2: AlbumList2
randomSongs: Songs
songsByGenre: Songs
lyrics: Lyrics
podcasts: Podcasts
newestPodcasts: NewestPodcasts
internetRadioStations: InternetRadioStations
bookmarks: Bookmarks
playQueue: PlayQueue
shares: Shares
starred: Starred
starred2: Starred2
albumInfo: AlbumInfo
artistInfo: ArtistInfo
artistInfo2: ArtistInfo2
similarSongs: SimilarSongs
similarSongs2: SimilarSongs2
topSongs: TopSongs
scanStatus: ScanStatus
error: Error
value: str
status: ResponseStatus
version: Version

File diff suppressed because it is too large Load Diff

View File

@@ -1,249 +0,0 @@
import json
import logging
import os
from enum import Enum
from typing import Any, Dict, List, Optional, Set
try:
import gi
gi.require_version('NM', '1.0')
from gi.repository import NM
networkmanager_imported = True
except Exception:
# I really don't care what kind of exception it is, all that matters is the
# import failed for some reason.
logging.warning(
'Unable to import NM from GLib. Detection of SSID will be disabled.')
networkmanager_imported = False
from .cache_manager import CacheManager
from .config import AppConfiguration
from .from_json import from_json
from .server.api_objects import Child
class RepeatType(Enum):
NO_REPEAT = 0
REPEAT_QUEUE = 1
REPEAT_SONG = 2
@property
def icon(self) -> str:
icon_name = [
'repeat-symbolic',
'repeat-symbolic',
'repeat-song-symbolic',
][self.value]
return f'media-playlist-{icon_name}'
def as_mpris_loop_status(self) -> str:
return ['None', 'Playlist', 'Track'][self.value]
@staticmethod
def from_mpris_loop_status(loop_status: str) -> 'RepeatType':
return {
'None': RepeatType.NO_REPEAT,
'Track': RepeatType.REPEAT_SONG,
'Playlist': RepeatType.REPEAT_QUEUE,
}[loop_status]
class ApplicationState:
"""
Represents the state of the application. In general, there are two things
that are stored here: configuration, and UI state.
Configuration is stored in ``config`` which is an ``AppConfiguration``
object. UI state is stored as separate properties on this class.
Configuration is stored to disk in $XDG_CONFIG_HOME/sublime-music. State is
stored in $XDG_CACHE_HOME. Nothing in state should be assumed to be
permanent. State need not be saved, the ``to_json`` and ``from_json``
functions define what part of the state will be saved across application
loads.
"""
version: int = 1
config: AppConfiguration = AppConfiguration()
config_file: Optional[str] = None
playing: bool = False
current_song_index: int = -1
play_queue: List[str] = []
old_play_queue: List[str] = []
_volume: Dict[str, float] = {'this device': 100.0}
is_muted: bool = False
repeat_type: RepeatType = RepeatType.NO_REPEAT
shuffle_on: bool = False
song_progress: float = 0
current_device: str = 'this device'
current_tab: str = 'albums'
selected_album_id: Optional[str] = None
selected_artist_id: Optional[str] = None
selected_browse_element_id: Optional[str] = None
selected_playlist_id: Optional[str] = None
# State for Album sort.
current_album_sort: str = 'random'
current_album_genre: str = 'Rock'
current_album_alphabetical_sort: str = 'name'
current_album_from_year: int = 2010
current_album_to_year: int = 2020
active_playlist_id: Optional[str] = None
if networkmanager_imported:
networkmanager_client = NM.Client.new()
nmclient_initialized = False
_current_ssids: Set[str] = set()
def to_json(self) -> Dict[str, Any]:
exclude = ('config', 'repeat_type', '_current_ssids')
json_object = {
k: getattr(self, k)
for k in self.__annotations__.keys()
if k not in exclude
}
json_object.update(
{
'repeat_type':
getattr(self, 'repeat_type', RepeatType.NO_REPEAT).value,
})
return json_object
def load_from_json(self, json_object: Dict[str, Any]):
self.version = json_object.get('version', 0)
self.current_song_index = json_object.get('current_song_index', -1)
self.play_queue = json_object.get('play_queue', [])
self.old_play_queue = json_object.get('old_play_queue', [])
self._volume = json_object.get('_volume', {'this device': 100.0})
self.is_muted = json_object.get('is_muted', False)
self.repeat_type = RepeatType(json_object.get('repeat_type', 0))
self.shuffle_on = json_object.get('shuffle_on', False)
self.song_progress = json_object.get('song_progress', 0.0)
self.current_device = json_object.get('current_device', 'this device')
self.current_tab = json_object.get('current_tab', 'albums')
self.selected_album_id = json_object.get('selected_album_id', None)
self.selected_artist_id = json_object.get('selected_artist_id', None)
self.selected_browse_element_id = json_object.get(
'selected_browse_element_id', None)
self.selected_playlist_id = json_object.get(
'selected_playlist_id', None)
self.current_album_sort = json_object.get(
'current_album_sort', 'random')
self.current_album_genre = json_object.get(
'current_album_genre', 'Rock')
self.current_album_alphabetical_sort = json_object.get(
'current_album_alphabetical_sort', 'name')
self.current_album_from_year = json_object.get(
'current_album_from_year', 2010)
self.current_album_to_year = json_object.get(
'current_album_to_year', 2020)
self.active_playlist_id = json_object.get('active_playlist_id', None)
def load(self):
self.config = self.get_config(self.config_file)
if self.config.server is None:
self.load_from_json({})
self.migrate()
return
CacheManager.reset(self.config, self.config.server, self.current_ssids)
if os.path.exists(self.state_filename):
with open(self.state_filename, 'r') as f:
try:
self.load_from_json(json.load(f))
except json.decoder.JSONDecodeError:
# Who cares, it's just state.
self.load_from_json({})
self.migrate()
def migrate(self):
"""Use this function to migrate any state storage that has changed."""
self.config.migrate()
self.save_config()
def save(self):
# Make the necessary directories before writing the state.
os.makedirs(os.path.dirname(self.state_filename), exist_ok=True)
# Save the state
state_json = json.dumps(self.to_json(), indent=2, sort_keys=True)
if not state_json:
return
with open(self.state_filename, 'w+') as f:
f.write(state_json)
def save_config(self):
# Make the necessary directories before writing the config.
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
config_json = json.dumps(
self.config.to_json(), indent=2, sort_keys=True)
if not config_json:
return
with open(self.config_file, 'w+') as f:
f.write(config_json)
def get_config(self, filename: str) -> AppConfiguration:
if not os.path.exists(filename):
return AppConfiguration()
with open(filename, 'r') as f:
try:
return from_json(AppConfiguration, json.load(f))
except json.decoder.JSONDecodeError:
return AppConfiguration()
@property
def current_ssids(self) -> Set[str]:
if not networkmanager_imported:
return set()
if not self.nmclient_initialized:
# Only look at the active WiFi connections.
for ac in self.networkmanager_client.get_active_connections():
if ac.get_connection_type() != '802-11-wireless':
continue
devs = ac.get_devices()
if len(devs) != 1:
continue
if devs[0].get_device_type() != NM.DeviceType.WIFI:
continue
self._current_ssids.add(ac.get_id())
return self._current_ssids
@property
def state_filename(self) -> str:
server_hash = CacheManager.calculate_server_hash(self.config.server)
if not server_hash:
raise Exception("Could not calculate the current server's hash.")
default_cache_location = (
os.environ.get('XDG_DATA_HOME')
or os.path.expanduser('~/.local/share'))
return os.path.join(
default_cache_location,
'sublime-music',
server_hash,
'state.yaml',
)
@property
def current_song(self) -> Optional[Child]:
if (not self.play_queue or self.current_song_index < 0
or not CacheManager.ready()):
return None
current_song_id = self.play_queue[self.current_song_index]
return CacheManager.get_song_details(current_song_id).result()
@property
def volume(self) -> float:
return self._volume.get(self.current_device, 100.0)
@volume.setter
def volume(self, value: float):
self._volume[self.current_device] = value

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,14 @@
min-width: 100px; min-width: 100px;
} }
#menu-item-add-to-playlist {
min-width: 170px;
}
#menu-item-spinner {
margin: 10px;
}
#playlist-album-artwork { #playlist-album-artwork {
min-height: 200px; min-height: 200px;
min-width: 200px; min-width: 200px;

View File

@@ -1,19 +1,11 @@
from datetime import timedelta
from random import randint from random import randint
from typing import Any, cast, List, Union from typing import Any, List, Sequence
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, GLib, GObject, Gtk, Pango from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.adapters import AdapterManager, api_objects as API
from sublime.server.api_objects import ( from sublime.config import AppConfiguration
AlbumID3,
ArtistID3,
ArtistInfo2,
ArtistWithAlbumsID3,
Child,
)
from sublime.state_manager import ApplicationState
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage
@@ -22,12 +14,12 @@ class ArtistsPanel(Gtk.Paned):
"""Defines the arist panel.""" """Defines the arist panel."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -42,14 +34,13 @@ class ArtistsPanel(Gtk.Paned):
self.artist_detail_panel = ArtistDetailPanel() self.artist_detail_panel = ArtistDetailPanel()
self.artist_detail_panel.connect( self.artist_detail_panel.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.pack2(self.artist_detail_panel, True, False) self.pack2(self.artist_detail_panel, True, False)
def update(self, state: ApplicationState, force: bool = False): def update(self, app_config: AppConfiguration, force: bool = False):
self.artist_list.update(state=state) self.artist_list.update(app_config=app_config)
self.artist_detail_panel.update(state=state) self.artist_detail_panel.update(app_config=app_config)
class _ArtistModel(GObject.GObject): class _ArtistModel(GObject.GObject):
@@ -57,11 +48,11 @@ class _ArtistModel(GObject.GObject):
name = GObject.Property(type=str) name = GObject.Property(type=str)
album_count = GObject.Property(type=int) album_count = GObject.Property(type=int)
def __init__(self, artist_id: str, name: str, album_count: int): def __init__(self, artist: API.Artist):
GObject.GObject.__init__(self) GObject.GObject.__init__(self)
self.artist_id = artist_id self.artist_id = artist.id
self.name = name self.name = artist.name
self.album_count = album_count self.album_count = artist.album_count or 0
class ArtistList(Gtk.Box): class ArtistList(Gtk.Box):
@@ -70,16 +61,15 @@ class ArtistList(Gtk.Box):
list_actions = Gtk.ActionBar() list_actions = Gtk.ActionBar()
refresh = IconButton( refresh = IconButton("view-refresh-symbolic", "Refresh list of artists")
'view-refresh-symbolic', 'Refresh list of artists') refresh.connect("clicked", lambda *a: self.update(force=True))
refresh.connect('clicked', lambda *a: self.update(force=True))
list_actions.pack_end(refresh) list_actions.pack_end(refresh)
self.add(list_actions) self.add(list_actions)
self.loading_indicator = Gtk.ListBox() self.loading_indicator = Gtk.ListBox()
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False)
spinner = Gtk.Spinner(name='artist-list-spinner', active=True) spinner = Gtk.Spinner(name="artist-list-spinner", active=True)
spinner_row.add(spinner) spinner_row.add(spinner)
self.loading_indicator.add(spinner_row) self.loading_indicator.add(spinner_row)
self.pack_start(self.loading_indicator, False, False, 0) self.pack_start(self.loading_indicator, False, False, 0)
@@ -87,60 +77,63 @@ class ArtistList(Gtk.Box):
list_scroll_window = Gtk.ScrolledWindow(min_content_width=250) list_scroll_window = Gtk.ScrolledWindow(min_content_width=250)
def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow: def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow:
label_text = [f'<b>{util.esc(model.name)}</b>'] label_text = [f"<b>{util.esc(model.name)}</b>"]
album_count = model.album_count album_count = model.album_count
if album_count: if album_count:
label_text.append( label_text.append(
'{} {}'.format( "{} {}".format(album_count, util.pluralize("album", album_count))
album_count, util.pluralize('album', album_count))) )
row = Gtk.ListBoxRow( row = Gtk.ListBoxRow(
action_name='app.go-to-artist', action_name="app.go-to-artist",
action_target=GLib.Variant('s', model.artist_id), action_target=GLib.Variant("s", model.artist_id),
) )
row.add( row.add(
Gtk.Label( Gtk.Label(
label='\n'.join(label_text), label="\n".join(label_text),
use_markup=True, use_markup=True,
margin=12, margin=12,
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
max_width_chars=30, )
)) )
row.show_all() row.show_all()
return row return row
self.artists_store = Gio.ListStore() self.artists_store = Gio.ListStore()
self.list = Gtk.ListBox(name='artist-list') self.list = Gtk.ListBox(name="artist-list")
self.list.bind_model(self.artists_store, create_artist_row) self.list.bind_model(self.artists_store, create_artist_row)
list_scroll_window.add(self.list) list_scroll_window.add(self.list)
self.pack_start(list_scroll_window, True, True, 0) self.pack_start(list_scroll_window, True, True, 0)
_app_config = None
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_artists(*a, **k), AdapterManager.get_artists,
before_download=lambda self: self.loading_indicator.show_all(), before_download=lambda self: self.loading_indicator.show_all(),
on_failure=lambda self, e: self.loading_indicator.hide(), on_failure=lambda self, e: self.loading_indicator.hide(),
) )
def update( def update(
self, self,
artists: List[ArtistID3], artists: Sequence[API.Artist],
state: ApplicationState, app_config: AppConfiguration = None,
**kwargs, **kwargs,
): ):
if app_config:
self._app_config = app_config
new_store = [] new_store = []
selected_idx = None selected_idx = None
for i, artist in enumerate(artists): for i, artist in enumerate(artists):
if state and state.selected_artist_id == artist.id: if (
self._app_config
and self._app_config.state
and self._app_config.state.selected_artist_id == artist.id
):
selected_idx = i selected_idx = i
new_store.append(_ArtistModel(artist))
new_store.append(
_ArtistModel(
artist.id,
artist.name,
artist.get('albumCount', ''),
))
util.diff_model_store(self.artists_store, new_store) util.diff_model_store(self.artists_store, new_store)
@@ -156,7 +149,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
"""Defines the artists list.""" """Defines the artists list."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
@@ -166,8 +159,8 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
update_order_token = 0 update_order_token = 0
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, name='artist-detail-panel', **kwargs) super().__init__(*args, name="artist-detail-panel", **kwargs)
self.albums: Union[List[AlbumID3], List[Child]] = [] self.albums: Sequence[API.Album] = []
self.artist_id = None self.artist_id = None
artist_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) artist_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
@@ -177,8 +170,8 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
self.artist_artwork = SpinnerImage( self.artist_artwork = SpinnerImage(
loading=False, loading=False,
image_name='artist-album-artwork', image_name="artist-album-artwork",
spinner_name='artist-artwork-spinner', spinner_name="artist-artwork-spinner",
image_size=300, image_size=300,
) )
self.big_info_panel.pack_start(self.artist_artwork, False, False, 0) self.big_info_panel.pack_start(self.artist_artwork, False, False, 0)
@@ -188,71 +181,66 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
# Action buttons (note we are packing end here, so we have to put them # Action buttons (note we are packing end here, so we have to put them
# in right-to-left). # in right-to-left).
self.artist_action_buttons = Gtk.Box( self.artist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
view_refresh_button = IconButton( view_refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
'view-refresh-symbolic', 'Refresh artist info') view_refresh_button.connect("clicked", self.on_view_refresh_click)
view_refresh_button.connect('clicked', self.on_view_refresh_click) self.artist_action_buttons.pack_end(view_refresh_button, False, False, 5)
self.artist_action_buttons.pack_end(
view_refresh_button, False, False, 5)
download_all_btn = IconButton( download_all_btn = IconButton(
'folder-download-symbolic', 'Download all songs by this artist') "folder-download-symbolic", "Download all songs by this artist"
download_all_btn.connect('clicked', self.on_download_all_click) )
download_all_btn.connect("clicked", self.on_download_all_click)
self.artist_action_buttons.pack_end(download_all_btn, False, False, 5) self.artist_action_buttons.pack_end(download_all_btn, False, False, 5)
artist_details_box.pack_start( artist_details_box.pack_start(self.artist_action_buttons, False, False, 5)
self.artist_action_buttons, False, False, 5)
artist_details_box.pack_start(Gtk.Box(), True, False, 0) artist_details_box.pack_start(Gtk.Box(), True, False, 0)
self.artist_indicator = self.make_label(name='artist-indicator') self.artist_indicator = self.make_label(name="artist-indicator")
artist_details_box.add(self.artist_indicator) artist_details_box.add(self.artist_indicator)
self.artist_name = self.make_label(name='artist-name') self.artist_name = self.make_label(name="artist-name")
artist_details_box.add(self.artist_name) artist_details_box.add(self.artist_name)
self.artist_bio = self.make_label( self.artist_bio = self.make_label(
name='artist-bio', justify=Gtk.Justification.LEFT) name="artist-bio", justify=Gtk.Justification.LEFT
)
self.artist_bio.set_line_wrap(True) self.artist_bio.set_line_wrap(True)
artist_details_box.add(self.artist_bio) artist_details_box.add(self.artist_bio)
self.similar_artists_scrolledwindow = Gtk.ScrolledWindow() self.similar_artists_scrolledwindow = Gtk.ScrolledWindow()
similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.similar_artists_label = self.make_label(name='similar-artists') self.similar_artists_label = self.make_label(name="similar-artists")
similar_artists_box.add(self.similar_artists_label) similar_artists_box.add(self.similar_artists_label)
self.similar_artists_button_box = Gtk.Box( self.similar_artists_button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL) orientation=Gtk.Orientation.HORIZONTAL
)
similar_artists_box.add(self.similar_artists_button_box) similar_artists_box.add(self.similar_artists_button_box)
self.similar_artists_scrolledwindow.add(similar_artists_box) self.similar_artists_scrolledwindow.add(similar_artists_box)
artist_details_box.add(self.similar_artists_scrolledwindow) artist_details_box.add(self.similar_artists_scrolledwindow)
self.artist_stats = self.make_label(name='artist-stats') self.artist_stats = self.make_label(name="artist-stats")
artist_details_box.add(self.artist_stats) artist_details_box.add(self.artist_stats)
self.play_shuffle_buttons = Gtk.Box( self.play_shuffle_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, orientation=Gtk.Orientation.HORIZONTAL,
name='playlist-play-shuffle-buttons', name="playlist-play-shuffle-buttons",
) )
play_button = IconButton( play_button = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic", label="Play All", relief=True,
label='Play All',
relief=True,
) )
play_button.connect('clicked', self.on_play_all_clicked) play_button.connect("clicked", self.on_play_all_clicked)
self.play_shuffle_buttons.pack_start(play_button, False, False, 0) self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
shuffle_button = IconButton( shuffle_button = IconButton(
'media-playlist-shuffle-symbolic', "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True,
label='Shuffle All',
relief=True,
) )
shuffle_button.connect('clicked', self.on_shuffle_all_button) shuffle_button.connect("clicked", self.on_shuffle_all_button)
self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
artist_details_box.add(self.play_shuffle_buttons) artist_details_box.add(self.play_shuffle_buttons)
@@ -262,119 +250,99 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
self.albums_list = AlbumsListWithSongs() self.albums_list = AlbumsListWithSongs()
self.albums_list.connect( self.albums_list.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
artist_info_box.pack_start(self.albums_list, True, True, 0) artist_info_box.pack_start(self.albums_list, True, True, 0)
self.add(artist_info_box) self.add(artist_info_box)
def update(self, state: ApplicationState): def update(self, app_config: AppConfiguration):
self.artist_id = state.selected_artist_id self.artist_id = app_config.state.selected_artist_id
if state.selected_artist_id is None: if app_config.state.selected_artist_id is None:
self.artist_action_buttons.hide() self.artist_action_buttons.hide()
self.artist_indicator.set_text('') self.artist_indicator.set_text("")
self.artist_name.set_markup('') self.artist_name.set_markup("")
self.artist_stats.set_markup('') self.artist_stats.set_markup("")
self.artist_bio.set_markup('') self.artist_bio.set_markup("")
self.similar_artists_scrolledwindow.hide() self.similar_artists_scrolledwindow.hide()
self.play_shuffle_buttons.hide() self.play_shuffle_buttons.hide()
self.artist_artwork.set_from_file(None) self.artist_artwork.set_from_file(None)
self.albums = cast(List[Child], []) self.albums = []
self.albums_list.update(None) self.albums_list.update(None)
else: else:
self.update_order_token += 1 self.update_order_token += 1
self.artist_action_buttons.show() self.artist_action_buttons.show()
self.update_artist_view( self.update_artist_view(
state.selected_artist_id, app_config.state.selected_artist_id,
state=state, app_config=app_config,
order_token=self.update_order_token, order_token=self.update_order_token,
) )
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_artist(*a, **k), AdapterManager.get_artist,
before_download=lambda self: self.set_all_loading(True), before_download=lambda self: self.set_all_loading(True),
on_failure=lambda self, e: self.set_all_loading(False), on_failure=lambda self, e: self.set_all_loading(False),
) )
def update_artist_view( def update_artist_view(
self, self,
artist: ArtistWithAlbumsID3, artist: API.Artist,
state: ApplicationState, app_config: AppConfiguration,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
): ):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return return
self.artist_indicator.set_text('ARTIST') self.artist_indicator.set_text("ARTIST")
self.artist_name.set_markup(util.esc(f'<b>{artist.name}</b>')) self.artist_name.set_markup(util.esc(f"<b>{artist.name}</b>"))
self.artist_stats.set_markup(self.format_stats(artist)) self.artist_stats.set_markup(self.format_stats(artist))
self.update_artist_info( self.artist_bio.set_markup(util.esc(artist.biography))
artist.id,
force=force,
order_token=order_token,
)
self.update_artist_artwork(
artist,
force=force,
order_token=order_token,
)
self.albums = artist.get('album', artist.get('child', [])) if len(artist.similar_artists or []) > 0:
self.albums_list.update(artist) self.similar_artists_label.set_markup("<b>Similar Artists:</b> ")
@util.async_callback(
lambda *a, **k: CacheManager.get_artist_info(*a, **k),
)
def update_artist_info(
self,
artist_info: ArtistInfo2,
state: ApplicationState,
force: bool = False,
order_token: int = None,
):
if order_token != self.update_order_token:
return
self.artist_bio.set_markup(util.esc(''.join(artist_info.biography)))
self.play_shuffle_buttons.show_all()
if len(artist_info.similarArtist or []) > 0:
self.similar_artists_label.set_markup('<b>Similar Artists:</b> ')
for c in self.similar_artists_button_box.get_children(): for c in self.similar_artists_button_box.get_children():
self.similar_artists_button_box.remove(c) self.similar_artists_button_box.remove(c)
for artist in artist_info.similarArtist[:5]: for artist in (artist.similar_artists or [])[:5]:
self.similar_artists_button_box.add( self.similar_artists_button_box.add(
Gtk.LinkButton( Gtk.LinkButton(
label=artist.name, label=artist.name,
name='similar-artist-button', name="similar-artist-button",
action_name='app.go-to-artist', action_name="app.go-to-artist",
action_target=GLib.Variant('s', artist.id), action_target=GLib.Variant("s", artist.id),
)) )
)
self.similar_artists_scrolledwindow.show_all() self.similar_artists_scrolledwindow.show_all()
else: else:
self.similar_artists_scrolledwindow.hide() self.similar_artists_scrolledwindow.hide()
self.play_shuffle_buttons.show_all()
self.update_artist_artwork(
artist.artist_image_url, force=force, order_token=order_token,
)
self.albums = artist.albums or []
self.albums_list.update(artist)
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_artist_artwork(*a, **k), AdapterManager.get_cover_art_filename,
before_download=lambda self: self.artist_artwork.set_loading(True), before_download=lambda self: self.artist_artwork.set_loading(True),
on_failure=lambda self, e: self.artist_artwork.set_loading(False), on_failure=lambda self, e: self.artist_artwork.set_loading(False),
) )
def update_artist_artwork( def update_artist_artwork(
self, self,
cover_art_filename: str, cover_art_filename: str,
state: ApplicationState, app_config: AppConfiguration,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
): ):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return return
self.artist_artwork.set_from_file(cover_art_filename) self.artist_artwork.set_from_file(cover_art_filename)
self.artist_artwork.set_loading(False) self.artist_artwork.set_loading(False)
@@ -382,41 +350,33 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
# ========================================================================= # =========================================================================
def on_view_refresh_click(self, *args): def on_view_refresh_click(self, *args):
self.update_artist_view( self.update_artist_view(
self.artist_id, self.artist_id, force=True, order_token=self.update_order_token,
force=True,
order_token=self.update_order_token,
) )
def on_download_all_click(self, btn: Any): def on_download_all_click(self, btn: Any):
CacheManager.batch_download_songs( AdapterManager.batch_download_songs(
self.get_artist_song_ids(), self.get_artist_song_ids(),
before_download=lambda: self.update_artist_view( before_download=lambda _: self.update_artist_view(
self.artist_id, self.artist_id, order_token=self.update_order_token,
order_token=self.update_order_token,
), ),
on_song_download_complete=lambda i: self.update_artist_view( on_song_download_complete=lambda _: self.update_artist_view(
self.artist_id, self.artist_id, order_token=self.update_order_token,
order_token=self.update_order_token,
), ),
) )
def on_play_all_clicked(self, btn: Any): def on_play_all_clicked(self, btn: Any):
songs = self.get_artist_song_ids() songs = self.get_artist_song_ids()
self.emit( self.emit(
'song-clicked', "song-clicked", 0, songs, {"force_shuffle_state": False},
0,
songs,
{'force_shuffle_state': False},
) )
def on_shuffle_all_button(self, btn: Any): def on_shuffle_all_button(self, btn: Any):
songs = self.get_artist_song_ids() songs = self.get_artist_song_ids()
self.emit( self.emit(
'song-clicked', "song-clicked",
randint(0, randint(0, len(songs) - 1),
len(songs) - 1),
songs, songs,
{'force_shuffle_state': True}, {"force_shuffle_state": True},
) )
# Helper Methods # Helper Methods
@@ -430,35 +390,30 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
self.albums_list.spinner.hide() self.albums_list.spinner.hide()
self.artist_artwork.set_loading(False) self.artist_artwork.set_loading(False)
def make_label( def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label:
self,
text: str = None,
name: str = None,
**params,
) -> Gtk.Label:
return Gtk.Label( return Gtk.Label(
label=text, label=text, name=name, halign=Gtk.Align.START, xalign=0, **params,
name=name,
halign=Gtk.Align.START,
xalign=0,
**params,
) )
def format_stats(self, artist: ArtistWithAlbumsID3) -> str: def format_stats(self, artist: API.Artist) -> str:
album_count = artist.get('albumCount', 0) album_count = artist.album_count or len(artist.albums or [])
song_count = sum(a.songCount for a in artist.album) song_count, duration = 0, timedelta(0)
duration = sum(a.duration for a in artist.album) for album in artist.albums or []:
song_count += album.song_count or 0
duration += album.duration or timedelta(0)
return util.dot_join( return util.dot_join(
'{} {}'.format(album_count, util.pluralize('album', album_count)), "{} {}".format(album_count, util.pluralize("album", album_count)),
'{} {}'.format(song_count, util.pluralize('song', song_count)), "{} {}".format(song_count, util.pluralize("song", song_count)),
util.format_sequence_duration(duration), util.format_sequence_duration(duration),
) )
def get_artist_song_ids(self) -> List[int]: def get_artist_song_ids(self) -> List[str]:
songs = [] songs = []
for album in CacheManager.get_artist(self.artist_id).result().album: for album in AdapterManager.get_artist(self.artist_id).result().albums or []:
album_songs = CacheManager.get_album(album.id).result() assert album.id
for song in album_songs.get('song', []): album_songs = AdapterManager.get_album(album.id).result()
for song in album_songs.songs or []:
songs.append(song.id) songs.append(song.id)
return songs return songs
@@ -466,7 +421,7 @@ class ArtistDetailPanel(Gtk.ScrolledWindow):
class AlbumsListWithSongs(Gtk.Overlay): class AlbumsListWithSongs(Gtk.Overlay):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
@@ -479,7 +434,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.add(self.box) self.add(self.box)
self.spinner = Gtk.Spinner( self.spinner = Gtk.Spinner(
name='albumslist-with-songs-spinner', name="albumslist-with-songs-spinner",
active=False, active=False,
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
@@ -488,7 +443,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.albums = [] self.albums = []
def update(self, artist: ArtistWithAlbumsID3): def update(self, artist: API.Artist):
def remove_all(): def remove_all():
for c in self.box.get_children(): for c in self.box.get_children():
self.box.remove(c) self.box.remove(c)
@@ -498,10 +453,13 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.spinner.hide() self.spinner.hide()
return return
new_albums = artist.get('album', artist.get('child', [])) new_albums = sorted(artist.albums or [], key=lambda a: a.name)
if self.albums == new_albums: if self.albums == new_albums:
# No need to do anything. # Just go through all of the colidren and update them.
for c in self.box.get_children():
c.update()
self.spinner.hide() self.spinner.hide()
return return
@@ -512,10 +470,9 @@ class AlbumsListWithSongs(Gtk.Overlay):
for album in self.albums: for album in self.albums:
album_with_songs = AlbumWithSongs(album, show_artist_name=False) album_with_songs = AlbumWithSongs(album, show_artist_name=False)
album_with_songs.connect( album_with_songs.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
album_with_songs.connect('song-selected', self.on_song_selected) album_with_songs.connect("song-selected", self.on_song_selected)
album_with_songs.show_all() album_with_songs.show_all()
self.box.add(album_with_songs) self.box.add(album_with_songs)

View File

@@ -1,12 +1,10 @@
from typing import Any, List, Optional, Tuple, Type, Union from functools import partial
from typing import Any, cast, List, Optional, Tuple
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.server.api_objects import Artist, Child, Directory from sublime.config import AppConfiguration
from sublime.state_manager import ApplicationState
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import IconButton, SongListColumn from sublime.ui.common import IconButton, SongListColumn
@@ -15,209 +13,187 @@ class BrowsePanel(Gtk.Overlay):
"""Defines the arist panel.""" """Defines the arist panel."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
), ),
} }
id_stack = None
update_order_token = 0 update_order_token = 0
def __init__(self): def __init__(self):
super().__init__() super().__init__()
scrolled_window = Gtk.ScrolledWindow() scrolled_window = Gtk.ScrolledWindow()
self.root_directory_listing = ListAndDrilldown(IndexList) self.root_directory_listing = ListAndDrilldown()
self.root_directory_listing.connect( self.root_directory_listing.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.root_directory_listing.connect( self.root_directory_listing.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
scrolled_window.add(self.root_directory_listing) scrolled_window.add(self.root_directory_listing)
self.add(scrolled_window) self.add(scrolled_window)
self.spinner = Gtk.Spinner( self.spinner = Gtk.Spinner(
name='browse-spinner', name="browse-spinner",
active=True, active=True,
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
) )
self.add_overlay(self.spinner) self.add_overlay(self.spinner)
def update(self, state: ApplicationState, force: bool = False): def update(self, app_config: AppConfiguration, force: bool = False):
if not CacheManager.ready: if not AdapterManager.can_get_directory():
return return
self.update_order_token += 1 self.update_order_token += 1
def do_update(id_stack: List[int], update_order_token: int): def do_update(update_order_token: int, id_stack: Tuple[str, ...]):
if self.update_order_token != update_order_token: if self.update_order_token != update_order_token:
return return
self.root_directory_listing.update( self.root_directory_listing.update(id_stack, app_config, force)
id_stack,
state=state,
force=force,
)
self.spinner.hide() self.spinner.hide()
def calculate_path(update_order_token: int) -> Tuple[List[str], int]: def calculate_path() -> Tuple[str, ...]:
if state.selected_browse_element_id is None: if (current_dir_id := app_config.state.selected_browse_element_id) is None:
return [], update_order_token return ("root",)
id_stack = [] id_stack = []
directory = None while current_dir_id and (
current_dir_id = state.selected_browse_element_id directory := AdapterManager.get_directory(
while directory is None or directory.parent is not None: current_dir_id, before_download=self.spinner.show,
directory = CacheManager.get_music_directory(
current_dir_id,
before_download=self.spinner.show,
).result() ).result()
):
id_stack.append(directory.id) id_stack.append(directory.id)
current_dir_id = directory.parent current_dir_id = directory.parent_id
return id_stack, update_order_token return tuple(id_stack)
path_fut = CacheManager.create_future( path_result: Result[Tuple[str, ...]] = Result(calculate_path)
calculate_path, path_result.add_done_callback(
self.update_order_token, lambda f: GLib.idle_add(
partial(do_update, self.update_order_token), f.result()
)
) )
path_fut.add_done_callback(
lambda f: GLib.idle_add(do_update, *f.result()))
class ListAndDrilldown(Gtk.Paned): class ListAndDrilldown(Gtk.Paned):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
), ),
} }
id_stack = None def __init__(self):
def __init__(self, list_type: Type):
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
self.list = list_type() self.list = MusicDirectoryList()
self.list.connect( self.list.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.list.connect( self.list.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
self.pack1(self.list, False, False) self.pack1(self.list, False, False)
self.drilldown = Gtk.Box() self.box = Gtk.Box()
self.pack2(self.drilldown, True, False) self.pack2(self.box, True, False)
def update( def update(
self, self,
id_stack: List[int], id_stack: Tuple[str, ...],
state: ApplicationState, app_config: AppConfiguration,
force: bool = False, force: bool = False,
directory_id: int = None,
): ):
*child_id_stack, dir_id = id_stack
selected_id = child_id_stack[-1] if len(child_id_stack) > 0 else None
self.list.update( self.list.update(
None if len(id_stack) == 0 else id_stack[-1], directory_id=dir_id,
state=state, selected_id=selected_id,
app_config=app_config,
force=force, force=force,
directory_id=directory_id,
) )
if self.id_stack == id_stack: children = self.box.get_children()
# We always want to update, but in this case, we don't want to blow if len(child_id_stack) == 0:
# away the drilldown. if len(children) > 0:
if isinstance(self.drilldown, ListAndDrilldown): self.box.remove(children[0])
self.drilldown.update( else:
id_stack[:-1], if len(children) == 0:
state, drilldown = ListAndDrilldown()
force=force, drilldown.connect(
directory_id=id_stack[-1], "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
) )
return drilldown.connect(
self.id_stack = id_stack "refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
self.box.add(drilldown)
self.box.show_all()
if len(id_stack) > 0: self.box.get_children()[0].update(
self.remove(self.drilldown) tuple(child_id_stack), app_config, force=force
self.drilldown = ListAndDrilldown(MusicDirectoryList)
self.drilldown.connect(
'song-clicked',
lambda _, *args: self.emit('song-clicked', *args),
) )
self.drilldown.connect(
'refresh-window',
lambda _, *args: self.emit('refresh-window', *args),
)
self.drilldown.update(
id_stack[:-1],
state,
force=force,
directory_id=id_stack[-1],
)
self.drilldown.show_all()
self.pack2(self.drilldown, True, False)
class DrilldownList(Gtk.Box): class MusicDirectoryList(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
), ),
} }
update_order_token = 0
directory_id: Optional[str] = None
selected_id: Optional[str] = None
class DrilldownElement(GObject.GObject): class DrilldownElement(GObject.GObject):
id = GObject.Property(type=str) id = GObject.Property(type=str)
name = GObject.Property(type=str) name = GObject.Property(type=str)
is_dir = GObject.Property(type=bool, default=True)
def __init__(self, element: Union[Child, Artist]): def __init__(self, element: API.Directory):
GObject.GObject.__init__(self) GObject.GObject.__init__(self)
self.id = element.id self.id = element.id
self.name = ( self.name = element.name
element.name if isinstance(element, Artist) else element.title)
self.is_dir = element.get('isDir', True)
def __init__(self): def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
list_actions = Gtk.ActionBar() list_actions = Gtk.ActionBar()
refresh = IconButton('view-refresh-symbolic', 'Refresh folder') refresh = IconButton("view-refresh-symbolic", "Refresh folder")
refresh.connect('clicked', self.on_refresh_clicked) refresh.connect("clicked", lambda *a: self.update(force=True))
list_actions.pack_end(refresh) list_actions.pack_end(refresh)
self.add(list_actions) self.add(list_actions)
self.loading_indicator = Gtk.ListBox() self.loading_indicator = Gtk.ListBox()
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False)
spinner = Gtk.Spinner(name='drilldown-list-spinner', active=True) spinner = Gtk.Spinner(name="drilldown-list-spinner", active=True)
spinner_row.add(spinner) spinner_row.add(spinner)
self.loading_indicator.add(spinner_row) self.loading_indicator.add(spinner_row)
self.pack_start(self.loading_indicator, False, False, 0) self.pack_start(self.loading_indicator, False, False, 0)
@@ -231,55 +207,186 @@ class DrilldownList(Gtk.Box):
scrollbox.add(self.list) scrollbox.add(self.list)
self.directory_song_store = Gtk.ListStore( self.directory_song_store = Gtk.ListStore(
str, # cache status str, str, str, str, # cache status, title, duration, song ID
str, # title
str, # duration
str, # song ID
) )
self.directory_song_list = Gtk.TreeView( self.directory_song_list = Gtk.TreeView(
model=self.directory_song_store, model=self.directory_song_store,
name='album-songs-list', name="directory-songs-list",
headers_visible=False, headers_visible=False,
) )
self.directory_song_list.get_selection().set_mode( self.directory_song_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
Gtk.SelectionMode.MULTIPLE)
# Song status column. # Song status column.
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35) renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0) column = Gtk.TreeViewColumn("", renderer, icon_name=0)
column.set_resizable(True) column.set_resizable(True)
self.directory_song_list.append_column(column) self.directory_song_list.append_column(column)
self.directory_song_list.append_column(SongListColumn("TITLE", 1, bold=True))
self.directory_song_list.append_column( self.directory_song_list.append_column(
SongListColumn('TITLE', 1, bold=True)) SongListColumn("DURATION", 2, align=1, width=40)
self.directory_song_list.append_column( )
SongListColumn('DURATION', 2, align=1, width=40))
self.directory_song_list.connect("row-activated", self.on_song_activated)
self.directory_song_list.connect( self.directory_song_list.connect(
'row-activated', self.on_song_activated) "button-press-event", self.on_song_button_press
self.directory_song_list.connect( )
'button-press-event', self.on_song_button_press)
scrollbox.add(self.directory_song_list) scrollbox.add(self.directory_song_list)
self.scroll_window.add(scrollbox) self.scroll_window.add(scrollbox)
self.pack_start(self.scroll_window, True, True, 0) self.pack_start(self.scroll_window, True, True, 0)
def update(
self,
app_config: AppConfiguration = None,
force: bool = False,
directory_id: str = None,
selected_id: str = None,
):
self.directory_id = directory_id or self.directory_id
self.selected_id = selected_id or self.selected_id
self.update_store(
self.directory_id, force=force, order_token=self.update_order_token,
)
_current_child_ids: List[str] = []
@util.async_callback(
AdapterManager.get_directory,
before_download=lambda self: self.loading_indicator.show(),
on_failure=lambda self, e: self.loading_indicator.hide(),
)
def update_store(
self,
directory: API.Directory,
app_config: AppConfiguration = None,
force: bool = False,
order_token: int = None,
):
if order_token != self.update_order_token:
return
# This doesn't look efficient, since it's doing a ton of passses over the data,
# but there is some annoying memory overhead for generating the stores to diff,
# so we are short-circuiting by checking to see if any of the the IDs have
# changed.
#
# The entire algorithm ends up being O(2n), but the first loop is very tight,
# and the expensive parts of the second loop are avoided if the IDs haven't
# changed.
children_ids, children, song_ids = [], [], []
selected_dir_idx = None
for i, c in enumerate(directory.children):
if i >= len(self._current_child_ids) or c.id != self._current_child_ids[i]:
force = True
if c.id == self.selected_id:
selected_dir_idx = i
children_ids.append(c.id)
children.append(c)
if not hasattr(c, "children"):
song_ids.append(c.id)
if force:
new_directories_store = []
self._current_child_ids = children_ids
songs = []
for el in children:
if hasattr(el, "children"):
new_directories_store.append(
MusicDirectoryList.DrilldownElement(cast(API.Directory, el))
)
else:
songs.append(cast(API.Song, el))
util.diff_model_store(
self.drilldown_directories_store, new_directories_store
)
new_songs_store = [
[
status_icon,
util.esc(song.title),
util.format_song_duration(song.duration),
song.id,
]
for status_icon, song in zip(
util.get_cached_status_icons(song_ids), songs
)
]
else:
new_songs_store = [
[status_icon] + song_model[1:]
for status_icon, song_model in zip(
util.get_cached_status_icons(song_ids), self.directory_song_store
)
]
util.diff_song_store(self.directory_song_store, new_songs_store)
if len(self.drilldown_directories_store) == 0:
self.list.hide()
else:
self.list.show()
if len(self.directory_song_store) == 0:
self.directory_song_list.hide()
self.scroll_window.set_min_content_width(275)
else:
self.directory_song_list.show()
self.scroll_window.set_min_content_width(350)
# Preserve selection
if selected_dir_idx is not None:
row = self.list.get_row_at_index(selected_dir_idx)
self.list.select_row(row)
self.loading_indicator.hide()
def on_download_state_change(self, _):
self.update()
# Create Element Helper Functions
# ==================================================================================
def create_row(self, model: DrilldownElement) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow(
action_name="app.browse-to", action_target=GLib.Variant("s", model.id),
)
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
rowbox.add(
Gtk.Label(
label=f"<b>{util.esc(model.name)}</b>",
use_markup=True,
margin=8,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
)
)
icon = Gio.ThemedIcon(name="go-next-symbolic")
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
rowbox.pack_end(image, False, False, 5)
row.add(rowbox)
row.show_all()
return row
# Event Handlers
# ==================================================================================
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.directory_song_store], [m[-1] for m in self.directory_song_store],
{}, {},
) )
def on_song_button_press( def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton,) -> bool:
self,
tree: Gtk.TreeView,
event: Gdk.EventButton,
) -> bool:
if event.button == 3: # Right click if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y) clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path: if not clicked_path:
@@ -297,10 +404,8 @@ class DrilldownList(Gtk.Box):
song_ids = [self.directory_song_store[p][-1] for p in paths] song_ids = [self.directory_song_store[p][-1] for p in paths]
# Used to adjust for the header row. # Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords( bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
event.x, event.y) widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
@@ -315,157 +420,3 @@ class DrilldownList(Gtk.Box):
return True return True
return False return False
def do_update_store(self, elements: Optional[List[Any]]):
new_directories_store = []
new_songs_store = []
selected_dir_idx = None
for idx, el in enumerate(elements or []):
if el.get('isDir', True):
new_directories_store.append(
DrilldownList.DrilldownElement(el))
if el.id == self.selected_id:
selected_dir_idx = idx
else:
new_songs_store.append(
[
util.get_cached_status_icon(
CacheManager.get_cached_status(el)),
util.esc(el.title),
util.format_song_duration(el.duration),
el.id,
])
util.diff_model_store(
self.drilldown_directories_store, new_directories_store)
util.diff_song_store(self.directory_song_store, new_songs_store)
if len(new_directories_store) == 0:
self.list.hide()
else:
self.list.show()
if len(new_songs_store) == 0:
self.directory_song_list.hide()
self.scroll_window.set_min_content_width(275)
else:
self.directory_song_list.show()
self.scroll_window.set_min_content_width(350)
# Preserve selection
if selected_dir_idx is not None:
row = self.list.get_row_at_index(selected_dir_idx)
self.list.select_row(row)
self.loading_indicator.hide()
def create_row(
self, model: 'DrilldownList.DrilldownElement') -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow(
action_name='app.browse-to',
action_target=GLib.Variant('s', model.id),
)
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
rowbox.add(
Gtk.Label(
label=f'<b>{util.esc(model.name)}</b>',
use_markup=True,
margin=8,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
))
icon = Gio.ThemedIcon(name='go-next-symbolic')
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
rowbox.pack_end(image, False, False, 5)
row.add(rowbox)
row.show_all()
return row
class IndexList(DrilldownList):
update_order_token = 0
def update(
self,
selected_id: int,
state: ApplicationState = None,
force: bool = False,
**kwargs,
):
self.update_order_token += 1
self.selected_id = selected_id
self.update_store(
force=force,
state=state,
order_token=self.update_order_token,
)
def on_refresh_clicked(self, _: Any):
self.update(self.selected_id, force=True)
@util.async_callback(
lambda *a, **k: CacheManager.get_indexes(*a, **k),
before_download=lambda self: self.loading_indicator.show(),
on_failure=lambda self, e: self.loading_indicator.hide(),
)
def update_store(
self,
artists: List[Artist],
state: ApplicationState = None,
force: bool = False,
order_token: int = None,
):
if order_token != self.update_order_token:
return
self.do_update_store(artists)
def on_download_state_change(self):
self.update(self.selected_id)
class MusicDirectoryList(DrilldownList):
update_order_token = 0
def update(
self,
selected_id: int,
state: ApplicationState = None,
force: bool = False,
directory_id: int = None,
):
self.directory_id = directory_id
self.selected_id = selected_id
self.update_store(
directory_id,
force=force,
state=state,
order_token=self.update_order_token,
)
def on_refresh_clicked(self, _: Any):
self.update(
self.selected_id, force=True, directory_id=self.directory_id)
@util.async_callback(
lambda *a, **k: CacheManager.get_music_directory(*a, **k),
before_download=lambda self: self.loading_indicator.show(),
on_failure=lambda self, e: self.loading_indicator.hide(),
)
def update_store(
self,
directory: Directory,
state: ApplicationState = None,
force: bool = False,
order_token: int = None,
):
if order_token != self.update_order_token:
return
self.do_update_store(directory.child)
def on_download_state_change(self):
self.update(self.selected_id, directory_id=self.directory_id)

View File

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

View File

@@ -1,13 +1,10 @@
from random import randint from random import randint
from typing import Any, Union from typing import Any, List
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, GLib, GObject, Gtk, Pango from gi.repository import Gdk, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.server.api_objects import AlbumWithSongsID3, Child, Directory from sublime.config import AppConfiguration
from sublime.state_manager import ApplicationState
from sublime.ui import util from sublime.ui import util
from sublime.ui.common.icon_button import IconButton from sublime.ui.common.icon_button import IconButton
from sublime.ui.common.song_list_column import SongListColumn from sublime.ui.common.song_list_column import SongListColumn
@@ -16,12 +13,8 @@ from sublime.ui.common.spinner_image import SpinnerImage
class AlbumWithSongs(Gtk.Box): class AlbumWithSongs(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'song-selected': ( "song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (),),
GObject.SignalFlags.RUN_FIRST, "song-clicked": (
GObject.TYPE_NONE,
(),
),
'song-clicked': (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
@@ -30,7 +23,7 @@ class AlbumWithSongs(Gtk.Box):
def __init__( def __init__(
self, self,
album: AlbumWithSongsID3, album: API.Album,
cover_art_size: int = 200, cover_art_size: int = 200,
show_artist_name: bool = True, show_artist_name: bool = True,
): ):
@@ -40,112 +33,102 @@ class AlbumWithSongs(Gtk.Box):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
artist_artwork = SpinnerImage( artist_artwork = SpinnerImage(
loading=False, loading=False,
image_name='artist-album-list-artwork', image_name="artist-album-list-artwork",
spinner_name='artist-artwork-spinner', spinner_name="artist-artwork-spinner",
image_size=cover_art_size, image_size=cover_art_size,
) )
# Account for 10px margin on all sides with "+ 20". # Account for 10px margin on all sides with "+ 20".
artist_artwork.set_size_request( artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
cover_art_size + 20, cover_art_size + 20)
box.pack_start(artist_artwork, False, False, 0) box.pack_start(artist_artwork, False, False, 0)
box.pack_start(Gtk.Box(), True, True, 0) box.pack_start(Gtk.Box(), True, True, 0)
self.pack_start(box, False, False, 0) self.pack_start(box, False, False, 0)
def cover_art_future_done(f: CacheManager.Result): def cover_art_future_done(f: Result):
artist_artwork.set_from_file(f.result()) artist_artwork.set_from_file(f.result())
artist_artwork.set_loading(False) artist_artwork.set_loading(False)
cover_art_filename_future = CacheManager.get_cover_art_filename( cover_art_filename_future = AdapterManager.get_cover_art_filename(
album.coverArt, album.cover_art, before_download=lambda: artist_artwork.set_loading(True),
before_download=lambda: artist_artwork.set_loading(True),
) )
cover_art_filename_future.add_done_callback( cover_art_filename_future.add_done_callback(
lambda f: GLib.idle_add(cover_art_future_done, f)) lambda f: GLib.idle_add(cover_art_future_done, f)
)
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
album_title_and_buttons = Gtk.Box( album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
# TODO (#43): deal with super long-ass titles # TODO (#43): deal with super long-ass titles
album_title_and_buttons.add( album_title_and_buttons.add(
Gtk.Label( Gtk.Label(
label=album.get('name', album.get('title')), label=album.name,
name='artist-album-list-album-name', name="artist-album-list-album-name",
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
)) )
)
self.play_btn = IconButton( self.play_btn = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic",
'Play all songs in this album', "Play all songs in this album",
sensitive=False, sensitive=False,
) )
self.play_btn.connect('clicked', self.play_btn_clicked) self.play_btn.connect("clicked", self.play_btn_clicked)
album_title_and_buttons.pack_start(self.play_btn, False, False, 5) album_title_and_buttons.pack_start(self.play_btn, False, False, 5)
self.shuffle_btn = IconButton( self.shuffle_btn = IconButton(
'media-playlist-shuffle-symbolic', "media-playlist-shuffle-symbolic",
'Shuffle all songs in this album', "Shuffle all songs in this album",
sensitive=False, sensitive=False,
) )
self.shuffle_btn.connect('clicked', self.shuffle_btn_clicked) self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked)
album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5) album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
self.play_next_btn = IconButton( self.play_next_btn = IconButton(
'go-top-symbolic', "go-top-symbolic",
'Play all of the songs in this album next', "Play all of the songs in this album next",
action_name='app.play-next', sensitive=False,
) )
album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5) album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
self.add_to_queue_btn = IconButton( self.add_to_queue_btn = IconButton(
'go-jump-symbolic', "go-jump-symbolic",
'Add all the songs in this album to the end of the play queue', "Add all the songs in this album to the end of the play queue",
action_name='app.add-to-queue',
)
album_title_and_buttons.pack_start(
self.add_to_queue_btn, False, False, 5)
self.download_all_btn = IconButton(
'folder-download-symbolic',
'Download all songs in this album',
sensitive=False, sensitive=False,
) )
self.download_all_btn.connect('clicked', self.on_download_all_click) album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False, 5)
album_title_and_buttons.pack_end(
self.download_all_btn, False, False, 5) self.download_all_btn = IconButton(
"folder-download-symbolic",
"Download all songs in this album",
sensitive=False,
)
self.download_all_btn.connect("clicked", self.on_download_all_click)
album_title_and_buttons.pack_end(self.download_all_btn, False, False, 5)
album_details.add(album_title_and_buttons) album_details.add(album_title_and_buttons)
stats = [ stats: List[Any] = [
album.artist if show_artist_name else None, album.artist.name if show_artist_name and album.artist else None,
album.year, album.year,
album.genre, album.genre.name if album.genre else None,
util.format_sequence_duration(album.duration) util.format_sequence_duration(album.duration) if album.duration else None,
if album.get('duration') else None,
] ]
album_details.add( album_details.add(
Gtk.Label( Gtk.Label(
label=util.dot_join(*stats), label=util.dot_join(*stats), halign=Gtk.Align.START, margin_left=10,
halign=Gtk.Align.START, )
margin_left=10,
))
self.album_song_store = Gtk.ListStore(
str, # cache status
str, # title
str, # duration
str, # song ID
) )
self.loading_indicator = Gtk.Spinner( self.loading_indicator_container = Gtk.Box()
name='album-list-song-list-spinner') album_details.add(self.loading_indicator_container)
album_details.add(self.loading_indicator)
# cache status, title, duration, song ID
self.album_song_store = Gtk.ListStore(str, str, str, str)
self.album_songs = Gtk.TreeView( self.album_songs = Gtk.TreeView(
model=self.album_song_store, model=self.album_song_store,
name='album-songs-list', name="album-songs-list",
headers_visible=False, headers_visible=False,
margin_top=15, margin_top=15,
margin_left=10, margin_left=10,
@@ -157,19 +140,18 @@ class AlbumWithSongs(Gtk.Box):
# Song status column. # Song status column.
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35) renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0) column = Gtk.TreeViewColumn("", renderer, icon_name=0)
column.set_resizable(True) column.set_resizable(True)
self.album_songs.append_column(column) self.album_songs.append_column(column)
self.album_songs.append_column(SongListColumn('TITLE', 1, bold=True)) self.album_songs.append_column(SongListColumn("TITLE", 1, bold=True))
self.album_songs.append_column( self.album_songs.append_column(SongListColumn("DURATION", 2, align=1, width=40))
SongListColumn('DURATION', 2, align=1, width=40))
self.album_songs.connect('row-activated', self.on_song_activated) self.album_songs.connect("row-activated", self.on_song_activated)
self.album_songs.connect( self.album_songs.connect("button-press-event", self.on_song_button_press)
'button-press-event', self.on_song_button_press)
self.album_songs.get_selection().connect( self.album_songs.get_selection().connect(
'changed', self.on_song_selection_change) "changed", self.on_song_selection_change
)
album_details.add(self.album_songs) album_details.add(self.album_songs)
self.pack_end(album_details, True, True, 0) self.pack_end(album_details, True, True, 0)
@@ -180,12 +162,12 @@ class AlbumWithSongs(Gtk.Box):
# ========================================================================= # =========================================================================
def on_song_selection_change(self, event: Any): def on_song_selection_change(self, event: Any):
if not self.album_songs.has_focus(): if not self.album_songs.has_focus():
self.emit('song-selected') self.emit("song-selected")
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.album_song_store], [m[-1] for m in self.album_song_store],
{}, {},
@@ -200,7 +182,7 @@ class AlbumWithSongs(Gtk.Box):
store, paths = tree.get_selection().get_selected_rows() store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False allow_deselect = False
def on_download_state_change(): def on_download_state_change(song_id: str):
self.update_album_songs(self.album.id) self.update_album_songs(self.album.id)
# Use the new selection instead of the old one for calculating what # Use the new selection instead of the old one for calculating what
@@ -212,10 +194,8 @@ class AlbumWithSongs(Gtk.Box):
song_ids = [self.album_song_store[p][-1] for p in paths] song_ids = [self.album_song_store[p][-1] for p in paths]
# Used to adjust for the header row. # Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords( bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
event.x, event.y) widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
@@ -232,29 +212,25 @@ class AlbumWithSongs(Gtk.Box):
return False return False
def on_download_all_click(self, btn: Any): def on_download_all_click(self, btn: Any):
CacheManager.batch_download_songs( AdapterManager.batch_download_songs(
[x[-1] for x in self.album_song_store], [x[-1] for x in self.album_song_store],
before_download=self.update, before_download=lambda _: self.update(),
on_song_download_complete=lambda x: self.update(), on_song_download_complete=lambda _: self.update(),
) )
def play_btn_clicked(self, btn: Any): def play_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store] song_ids = [x[-1] for x in self.album_song_store]
self.emit( self.emit(
'song-clicked', "song-clicked", 0, song_ids, {"force_shuffle_state": False},
0,
song_ids,
{'force_shuffle_state': False},
) )
def shuffle_btn_clicked(self, btn: Any): def shuffle_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store] song_ids = [x[-1] for x in self.album_song_store]
self.emit( self.emit(
'song-clicked', "song-clicked",
randint(0, randint(0, len(self.album_song_store) - 1),
len(self.album_song_store) - 1),
song_ids, song_ids,
{'force_shuffle_state': True}, {"force_shuffle_state": True},
) )
# Helper Methods # Helper Methods
@@ -263,47 +239,58 @@ class AlbumWithSongs(Gtk.Box):
self.album_songs.get_selection().unselect_all() self.album_songs.get_selection().unselect_all()
def update(self, force: bool = False): def update(self, force: bool = False):
self.update_album_songs(self.album.id) self.update_album_songs(self.album.id, force=force)
def set_loading(self, loading: bool): def set_loading(self, loading: bool):
if loading: if loading:
self.loading_indicator.start() if len(self.loading_indicator_container.get_children()) == 0:
self.loading_indicator.show() self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
spinner = Gtk.Spinner(name="album-list-song-list-spinner")
spinner.start()
self.loading_indicator_container.add(spinner)
self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
self.loading_indicator_container.show_all()
else: else:
self.loading_indicator.stop() self.loading_indicator_container.hide()
self.loading_indicator.hide()
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_album(*a, **k), AdapterManager.get_album,
before_download=lambda self: self.set_loading(True), before_download=lambda self: self.set_loading(True),
on_failure=lambda self, e: self.set_loading(False), on_failure=lambda self, e: self.set_loading(False),
) )
def update_album_songs( def update_album_songs(
self, self,
album: Union[AlbumWithSongsID3, Child, Directory], album: API.Album,
state: ApplicationState, app_config: AppConfiguration,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
): ):
song_ids = [s.id for s in album.songs or []]
new_store = [ new_store = [
[ [
util.get_cached_status_icon( cached_status,
CacheManager.get_cached_status(song)),
util.esc(song.title), util.esc(song.title),
util.format_song_duration(song.duration), util.format_song_duration(song.duration),
song.id, song.id,
] for song in (album.get('child') or album.get('song') or []) ]
for cached_status, song in zip(
util.get_cached_status_icons(song_ids), album.songs or []
)
] ]
song_ids = [song[-1] for song in new_store] song_ids = [song[-1] for song in new_store]
self.play_btn.set_sensitive(True) self.play_btn.set_sensitive(True)
self.shuffle_btn.set_sensitive(True) self.shuffle_btn.set_sensitive(True)
self.play_next_btn.set_action_target_value( self.download_all_btn.set_sensitive(AdapterManager.can_batch_download_songs())
GLib.Variant('as', song_ids))
self.add_to_queue_btn.set_action_target_value( self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
GLib.Variant('as', song_ids)) self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
self.download_all_btn.set_sensitive(True) self.play_next_btn.set_action_name("app.add-to-queue")
self.add_to_queue_btn.set_action_name("app.play-next")
util.diff_song_store(self.album_song_store, new_store) util.diff_song_store(self.album_song_store, new_store)
self.loading_indicator.hide()
# Have to idle_add here so that his happens after the component is rendered.
self.set_loading(False)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,31 @@
from datetime import datetime from functools import partial
from typing import Any, Callable, Set from typing import Any, Optional, Set
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager, SearchResult from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.state_manager import ApplicationState from sublime.config import AppConfiguration
from sublime.ui import ( from sublime.ui import albums, artists, browse, player_controls, playlists, util
albums, artists, browse, player_controls, playlists, util) from sublime.ui.common import IconButton, SpinnerImage
from sublime.ui.common import SpinnerImage
class MainWindow(Gtk.ApplicationWindow): class MainWindow(Gtk.ApplicationWindow):
"""Defines the main window for Sublime Music.""" """Defines the main window for Sublime Music."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'songs-removed': ( "songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
GObject.SignalFlags.RUN_FIRST, "refresh-window": (
GObject.TYPE_NONE,
(object, ),
),
'refresh-window': (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
), ),
'go-to': ( "notification-closed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (),),
GObject.SignalFlags.RUN_FIRST, "go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str),),
GObject.TYPE_NONE,
(str, str),
),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -48,58 +39,101 @@ class MainWindow(Gtk.ApplicationWindow):
Browse=browse.BrowsePanel(), Browse=browse.BrowsePanel(),
Playlists=playlists.PlaylistsPanel(), Playlists=playlists.PlaylistsPanel(),
) )
self.stack.set_transition_type( self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
self.titlebar = self._create_headerbar(self.stack) self.titlebar = self._create_headerbar(self.stack)
self.set_titlebar(self.titlebar) self.set_titlebar(self.titlebar)
self.player_controls = player_controls.PlayerControls() flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.player_controls.connect( notification_container = Gtk.Overlay()
'song-clicked', lambda _, *a: self.emit('song-clicked', *a))
self.player_controls.connect( notification_container.add(self.stack)
'songs-removed', lambda _, *a: self.emit('songs-removed', *a))
self.player_controls.connect( self.notification_revealer = Gtk.Revealer(
'refresh-window', valign=Gtk.Align.END, halign=Gtk.Align.CENTER
lambda _, *args: self.emit('refresh-window', *args),
) )
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) notification_box = Gtk.Box(can_focus=False, valign="start", spacing=10)
flowbox.pack_start(self.stack, True, True, 0) notification_box.get_style_context().add_class("app-notification")
self.notification_text = Gtk.Label(use_markup=True)
notification_box.pack_start(self.notification_text, True, False, 0)
self.notification_actions = Gtk.Box()
notification_box.pack_start(self.notification_actions, True, False, 0)
notification_box.add(close_button := IconButton("window-close-symbolic"))
close_button.connect("clicked", lambda _: self.emit("notification-closed"))
self.notification_revealer.add(notification_box)
notification_container.add_overlay(self.notification_revealer)
flowbox.pack_start(notification_container, True, True, 0)
# Player Controls
self.player_controls = player_controls.PlayerControls()
self.player_controls.connect(
"song-clicked", lambda _, *a: self.emit("song-clicked", *a)
)
self.player_controls.connect(
"songs-removed", lambda _, *a: self.emit("songs-removed", *a)
)
self.player_controls.connect(
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
)
flowbox.pack_start(self.player_controls, False, True, 0) flowbox.pack_start(self.player_controls, False, True, 0)
self.add(flowbox) self.add(flowbox)
self.connect('button-release-event', self._on_button_release) self.connect("button-release-event", self._on_button_release)
current_notification_hash = None
def update(self, app_config: AppConfiguration, force: bool = False):
notification = app_config.state.current_notification
if notification and (h := hash(notification)) != self.current_notification_hash:
self.current_notification_hash = h
self.notification_text.set_markup(notification.markup)
for c in self.notification_actions.get_children():
self.notification_actions.remove(c)
for label, fn in notification.actions:
self.notification_actions.add(action_button := Gtk.Button(label=label))
action_button.connect("clicked", lambda _: fn())
self.notification_revealer.show_all()
self.notification_revealer.set_reveal_child(True)
if notification is None:
self.notification_revealer.set_reveal_child(False)
def update(self, state: ApplicationState, force: bool = False):
# Update the Connected to label on the popup menu. # Update the Connected to label on the popup menu.
if state.config.current_server >= 0: if app_config.server:
server_name = state.config.servers[
state.config.current_server].name
self.connected_to_label.set_markup( self.connected_to_label.set_markup(
f'<b>Connected to {server_name}</b>') f"<b>Connected to {app_config.server.name}</b>"
)
else: else:
self.connected_to_label.set_markup( self.connected_to_label.set_markup(
f'<span style="italic">Not Connected to a Server</span>') '<span style="italic">Not Connected to a Server</span>'
)
self.stack.set_visible_child_name(state.current_tab) self.stack.set_visible_child_name(app_config.state.current_tab)
active_panel = self.stack.get_visible_child() active_panel = self.stack.get_visible_child()
if hasattr(active_panel, 'update'): if hasattr(active_panel, "update"):
active_panel.update(state, force=force) active_panel.update(app_config, force=force)
self.player_controls.update(state) self.player_controls.update(app_config)
def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack: def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack:
stack = Gtk.Stack() stack = Gtk.Stack()
for name, child in kwargs.items(): for name, child in kwargs.items():
child.connect( child.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
child.connect( child.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
stack.add_titled(child, name.lower(), name) stack.add_titled(child, name.lower(), name)
return stack return stack
@@ -110,20 +144,17 @@ class MainWindow(Gtk.ApplicationWindow):
""" """
header = Gtk.HeaderBar() header = Gtk.HeaderBar()
header.set_show_close_button(True) header.set_show_close_button(True)
header.props.title = 'Sublime Music' header.props.title = "Sublime Music"
# Search # Search
self.search_entry = Gtk.SearchEntry( self.search_entry = Gtk.SearchEntry(placeholder_text="Search everything...")
placeholder_text='Search everything...') self.search_entry.connect("focus-in-event", self._on_search_entry_focus)
self.search_entry.connect( self.search_entry.connect(
'focus-in-event', self._on_search_entry_focus) "button-press-event", self._on_search_entry_button_press
self.search_entry.connect( )
'button-press-event', self._on_search_entry_button_press) self.search_entry.connect("focus-out-event", self._on_search_entry_loose_focus)
self.search_entry.connect( self.search_entry.connect("changed", self._on_search_entry_changed)
'focus-out-event', self._on_search_entry_loose_focus) self.search_entry.connect("stop-search", self._on_search_entry_stop_search)
self.search_entry.connect('changed', self._on_search_entry_changed)
self.search_entry.connect(
'stop-search', self._on_search_entry_stop_search)
header.pack_start(self.search_entry) header.pack_start(self.search_entry)
# Search popup # Search popup
@@ -135,13 +166,13 @@ class MainWindow(Gtk.ApplicationWindow):
# Menu button # Menu button
menu_button = Gtk.MenuButton() menu_button = Gtk.MenuButton()
menu_button.set_tooltip_text('Open application menu') menu_button.set_tooltip_text("Open application menu")
menu_button.set_use_popover(True) menu_button.set_use_popover(True)
menu_button.set_popover(self._create_menu()) menu_button.set_popover(self._create_menu())
menu_button.connect('clicked', self._on_menu_clicked) menu_button.connect("clicked", self._on_menu_clicked)
self.menu.set_relative_to(menu_button) self.menu.set_relative_to(menu_button)
icon = Gio.ThemedIcon(name='open-menu-symbolic') icon = Gio.ThemedIcon(name="open-menu-symbolic")
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON) image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
menu_button.add(image) menu_button.add(image)
@@ -158,31 +189,28 @@ class MainWindow(Gtk.ApplicationWindow):
**kwargs, **kwargs,
) )
label.set_markup(text) label.set_markup(text)
label.get_style_context().add_class('search-result-row') label.get_style_context().add_class("search-result-row")
return label return label
def _create_menu(self) -> Gtk.PopoverMenu: def _create_menu(self) -> Gtk.PopoverMenu:
self.menu = Gtk.PopoverMenu() self.menu = Gtk.PopoverMenu()
self.connected_to_label = self._create_label( self.connected_to_label = self._create_label("", name="connected-to-label")
'', name='connected-to-label')
self.connected_to_label.set_markup( self.connected_to_label.set_markup(
f'<span style="italic">Not Connected to a Server</span>') '<span style="italic">Not Connected to a Server</span>'
)
menu_items = [ menu_items = [
(None, self.connected_to_label), (None, self.connected_to_label),
( ("app.configure-servers", Gtk.ModelButton(text="Configure Servers"),),
'app.configure-servers', ("app.settings", Gtk.ModelButton(text="Settings")),
Gtk.ModelButton(text='Configure Servers'),
),
('app.settings', Gtk.ModelButton(text='Settings')),
] ]
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
for name, item in menu_items: for name, item in menu_items:
if name: if name:
item.set_action_name(name) item.set_action_name(name)
item.get_style_context().add_class('menu-button') item.get_style_context().add_class("menu-button")
vbox.pack_start(item, False, True, 0) vbox.pack_start(item, False, True, 0)
self.menu.add(vbox) self.menu.add(vbox)
@@ -192,36 +220,33 @@ class MainWindow(Gtk.ApplicationWindow):
self.search_popup = Gtk.PopoverMenu(modal=False) self.search_popup = Gtk.PopoverMenu(modal=False)
results_scrollbox = Gtk.ScrolledWindow( results_scrollbox = Gtk.ScrolledWindow(
min_content_width=500, min_content_width=500, min_content_height=750,
min_content_height=750,
) )
def make_search_result_header(text: str) -> Gtk.Label: def make_search_result_header(text: str) -> Gtk.Label:
label = self._create_label(text) label = self._create_label(text)
label.get_style_context().add_class('search-result-header') label.get_style_context().add_class("search-result-header")
return label return label
search_results_box = Gtk.Box( search_results_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL, name="search-results",
name='search-results',
) )
self.search_results_loading = Gtk.Spinner( self.search_results_loading = Gtk.Spinner(active=False, name="search-spinner")
active=False, name='search-spinner')
search_results_box.add(self.search_results_loading) search_results_box.add(self.search_results_loading)
search_results_box.add(make_search_result_header('Songs')) search_results_box.add(make_search_result_header("Songs"))
self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.song_results) search_results_box.add(self.song_results)
search_results_box.add(make_search_result_header('Albums')) search_results_box.add(make_search_result_header("Albums"))
self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.album_results) search_results_box.add(self.album_results)
search_results_box.add(make_search_result_header('Artists')) search_results_box.add(make_search_result_header("Artists"))
self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.artist_results) search_results_box.add(self.artist_results)
search_results_box.add(make_search_result_header('Playlists')) search_results_box.add(make_search_result_header("Playlists"))
self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.playlist_results) search_results_box.add(self.playlist_results)
@@ -240,24 +265,20 @@ class MainWindow(Gtk.ApplicationWindow):
# Event Listeners # Event Listeners
# ========================================================================= # =========================================================================
def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool: def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool:
if not self._event_in_widgets( if not self._event_in_widgets(event, self.search_entry, self.search_popup,):
event,
self.search_entry,
self.search_popup,
):
self._hide_search() self._hide_search()
if not self._event_in_widgets( if not self._event_in_widgets(
event, event,
self.player_controls.device_button, self.player_controls.device_button,
self.player_controls.device_popover, self.player_controls.device_popover,
): ):
self.player_controls.device_popover.popdown() self.player_controls.device_popover.popdown()
if not self._event_in_widgets( if not self._event_in_widgets(
event, event,
self.player_controls.play_queue_button, self.player_controls.play_queue_button,
self.player_controls.play_queue_popover, self.player_controls.play_queue_popover,
): ):
self.player_controls.play_queue_popover.popdown() self.player_controls.play_queue_popover.popdown()
@@ -277,50 +298,41 @@ class MainWindow(Gtk.ApplicationWindow):
self._hide_search() self._hide_search()
search_idx = 0 search_idx = 0
latest_returned_search_idx = 0 searches: Set[Result] = set()
last_search_change_time = datetime.now()
searches: Set[CacheManager.Result] = set()
def _on_search_entry_changed(self, entry: Gtk.Entry): def _on_search_entry_changed(self, entry: Gtk.Entry):
now = datetime.now() while len(self.searches) > 0:
if (now - self.last_search_change_time).total_seconds() < 0.5: search = self.searches.pop()
while len(self.searches) > 0: if search:
search = self.searches.pop() search.cancel()
if search:
search.cancel()
self.last_search_change_time = now
if not self.search_popup.is_visible(): if not self.search_popup.is_visible():
self.search_popup.show_all() self.search_popup.show_all()
self.search_popup.popup() self.search_popup.popup()
def create_search_callback(idx: int) -> Callable[..., Any]: def search_result_calback(idx: int, result: API.SearchResult):
def search_result_calback( # Ignore slow returned searches.
result: SearchResult, if idx < self.search_idx:
is_last_in_batch: bool, return
):
# Ignore slow returned searches.
if idx < self.latest_returned_search_idx:
return
# If all results are back, the stop the loading indicator. GLib.idle_add(self._update_search_results, result)
if is_last_in_batch:
if idx == self.search_idx - 1:
self._set_search_loading(False)
self.latest_returned_search_idx = idx
self._update_search_results(result) def search_result_done(r: Result):
if not r.result():
# The search was cancelled
return
return lambda *a: GLib.idle_add(search_result_calback, *a) # If all results are back, the stop the loading indicator.
GLib.idle_add(self._set_search_loading, False)
self.searches.add(
CacheManager.search(
entry.get_text(),
search_callback=create_search_callback(self.search_idx),
before_download=lambda: self._set_search_loading(True),
))
self.search_idx += 1 self.search_idx += 1
search_result = AdapterManager.search(
entry.get_text(),
search_callback=partial(search_result_calback, self.search_idx),
before_download=lambda: self._set_search_loading(True),
)
search_result.add_done_callback(search_result_done)
self.searches.add(search_result)
def _on_search_entry_stop_search(self, entry: Any): def _on_search_entry_stop_search(self, entry: Any):
self.search_popup.popdown() self.search_popup.popdown()
@@ -350,93 +362,89 @@ class MainWindow(Gtk.ApplicationWindow):
widget.remove(c) widget.remove(c)
def _create_search_result_row( def _create_search_result_row(
self, self, text: str, action_name: str, id: str, cover_art_id: Optional[str]
text: str,
action_name: str,
value: Any,
artwork_future: CacheManager.Result,
) -> Gtk.Button: ) -> Gtk.Button:
def on_search_row_button_press(*args): def on_search_row_button_press(*args):
if action_name == 'song': self.emit("go-to", action_name, id)
goto_action_name, goto_id = 'album', value.albumId
else:
goto_action_name, goto_id = action_name, value.id
self.emit('go-to', goto_action_name, goto_id)
self._hide_search() self._hide_search()
row = Gtk.Button(relief=Gtk.ReliefStyle.NONE) row = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
row.connect('button-press-event', on_search_row_button_press) row.connect("button-press-event", on_search_row_button_press)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
image = SpinnerImage(image_name='search-artwork', image_size=30) image = SpinnerImage(image_name="search-artwork", image_size=30)
box.add(image) box.add(image)
box.add(self._create_label(text)) box.add(self._create_label(text))
row.add(box) row.add(box)
def image_callback(f: CacheManager.Result): def image_callback(f: Result):
image.set_loading(False) image.set_loading(False)
image.set_from_file(f.result()) image.set_from_file(f.result())
artwork_future.add_done_callback( artwork_future = AdapterManager.get_cover_art_filename(cover_art_id)
lambda f: GLib.idle_add(image_callback, f)) artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f))
return row return row
def _update_search_results(self, search_results: SearchResult): def _update_search_results(self, search_results: API.SearchResult):
# Songs # Songs
if search_results.song is not None: if search_results.songs is not None:
self._remove_all_from_widget(self.song_results) self._remove_all_from_widget(self.song_results)
for song in search_results.song or []: for song in search_results.songs:
label_text = util.dot_join( label_text = util.dot_join(
f'<b>{util.esc(song.title)}</b>', f"<b>{util.esc(song.title)}</b>",
util.esc(song.artist), util.esc(song.artist.name if song.artist else None),
) )
cover_art_future = CacheManager.get_cover_art_filename( assert song.album and song.album.id
song.coverArt)
self.song_results.add( self.song_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'song', song, cover_art_future)) label_text, "album", song.album.id, song.cover_art
)
)
self.song_results.show_all() self.song_results.show_all()
# Albums # Albums
if search_results.album is not None: if search_results.albums is not None:
self._remove_all_from_widget(self.album_results) self._remove_all_from_widget(self.album_results)
for album in search_results.album or []: for album in search_results.albums:
label_text = util.dot_join( label_text = util.dot_join(
f'<b>{util.esc(album.name)}</b>', f"<b>{util.esc(album.name)}</b>",
util.esc(album.artist), util.esc(album.artist.name if album.artist else None),
) )
cover_art_future = CacheManager.get_cover_art_filename( assert album.id
album.coverArt)
self.album_results.add( self.album_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'album', album, cover_art_future)) label_text, "album", album.id, album.cover_art
)
)
self.album_results.show_all() self.album_results.show_all()
# Artists # Artists
if search_results.artist is not None: if search_results.artists is not None:
self._remove_all_from_widget(self.artist_results) self._remove_all_from_widget(self.artist_results)
for artist in search_results.artist or []: for artist in search_results.artists:
label_text = util.esc(artist.name) label_text = util.esc(artist.name)
cover_art_future = CacheManager.get_artist_artwork(artist) assert artist.id
self.artist_results.add( self.artist_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'artist', artist, cover_art_future)) label_text, "artist", artist.id, artist.artist_image_url
)
)
self.artist_results.show_all() self.artist_results.show_all()
# Playlists # Playlists
if search_results.playlist is not None: if search_results.playlists:
self._remove_all_from_widget(self.playlist_results) self._remove_all_from_widget(self.playlist_results)
for playlist in search_results.playlist or []: for playlist in search_results.playlists:
label_text = util.esc(playlist.name) label_text = util.esc(playlist.name)
cover_art_future = CacheManager.get_cover_art_filename(
playlist.coverArt)
self.playlist_results.add( self.playlist_results.add(
self._create_search_result_row( self._create_search_result_row(
label_text, 'playlist', playlist, cover_art_future)) label_text, "playlist", playlist.id, playlist.cover_art
)
)
self.playlist_results.show_all() self.playlist_results.show_all()
@@ -453,8 +461,9 @@ class MainWindow(Gtk.ApplicationWindow):
bound_y = (win_y + widget_y, win_y + widget_y + allocation.height) bound_y = (win_y + widget_y, win_y + widget_y + allocation.height)
# If the event is in this widget, return True immediately. # If the event is in this widget, return True immediately.
if ((bound_x[0] <= event.x_root <= bound_x[1]) if (bound_x[0] <= event.x_root <= bound_x[1]) and (
and (bound_y[0] <= event.y_root <= bound_y[1])): bound_y[0] <= event.y_root <= bound_y[1]
):
return True return True
return False return False

View File

@@ -1,53 +1,37 @@
import math import math
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Callable, List from typing import Any, Callable, List, Optional, Tuple
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
from pychromecast import Chromecast from pychromecast import Chromecast
from sublime.cache_manager import CacheManager from sublime.adapters import AdapterManager, Result
from sublime.adapters.api_objects import Song
from sublime.config import AppConfiguration
from sublime.players import ChromecastPlayer from sublime.players import ChromecastPlayer
from sublime.server.api_objects import Child
from sublime.state_manager import ApplicationState, RepeatType
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import IconButton, IconToggleButton, SpinnerImage from sublime.ui.common import IconButton, IconToggleButton, SpinnerImage
from sublime.ui.state import RepeatType
class PlayerControls(Gtk.ActionBar): class PlayerControls(Gtk.ActionBar):
""" """
Defines the player controls panel that appears at the bottom of the window. Defines the player controls panel that appears at the bottom of the window.
""" """
__gsignals__ = { __gsignals__ = {
'song-scrub': ( "song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),),
GObject.SignalFlags.RUN_FIRST, "volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,),),
GObject.TYPE_NONE, "device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,),),
(float, ), "song-clicked": (
),
'volume-change': (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(float, ),
),
'device-update': (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(str, ),
),
'song-clicked': (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'songs-removed': ( "songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,),),
GObject.SignalFlags.RUN_FIRST, "refresh-window": (
GObject.TYPE_NONE,
(object, ),
),
'refresh-window': (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -58,6 +42,8 @@ class PlayerControls(Gtk.ActionBar):
reordering_play_queue_song_list: bool = False reordering_play_queue_song_list: bool = False
current_song = None current_song = None
current_device = None current_device = None
current_playing_index: Optional[int] = None
current_play_queue: Tuple[str, ...] = ()
chromecasts: List[ChromecastPlayer] = [] chromecasts: List[ChromecastPlayer] = []
cover_art_update_order_token = 0 cover_art_update_order_token = 0
play_queue_update_order_token = 0 play_queue_update_order_token = 0
@@ -65,7 +51,7 @@ class PlayerControls(Gtk.ActionBar):
def __init__(self): def __init__(self):
Gtk.ActionBar.__init__(self) Gtk.ActionBar.__init__(self)
self.set_name('player-controls-bar') self.set_name("player-controls-bar")
song_display = self.create_song_display() song_display = self.create_song_display()
playback_controls = self.create_playback_controls() playback_controls = self.create_playback_controls()
@@ -77,37 +63,52 @@ class PlayerControls(Gtk.ActionBar):
self.set_center_widget(playback_controls) self.set_center_widget(playback_controls)
self.pack_end(play_queue_volume) self.pack_end(play_queue_volume)
def update(self, state: ApplicationState): def update(self, app_config: AppConfiguration):
self.current_device = state.current_device self.current_device = app_config.state.current_device
duration = (
app_config.state.current_song.duration
if app_config.state.current_song
else None
)
song_stream_cache_progress = (
app_config.state.song_stream_cache_progress
if app_config.state.current_song
else None
)
self.update_scrubber( self.update_scrubber(
getattr(state, 'song_progress', None), app_config.state.song_progress, duration, song_stream_cache_progress
getattr(state.current_song, 'duration', None),
) )
icon = 'pause' if state.playing else 'start' icon = "pause" if app_config.state.playing else "start"
self.play_button.set_icon(f"media-playback-{icon}-symbolic") self.play_button.set_icon(f"media-playback-{icon}-symbolic")
self.play_button.set_tooltip_text('Pause' if state.playing else 'Play') self.play_button.set_tooltip_text(
"Pause" if app_config.state.playing else "Play"
)
has_current_song = state.current_song is not None has_current_song = app_config.state.current_song is not None
has_next_song = False has_next_song = False
if state.repeat_type in (RepeatType.REPEAT_QUEUE, if app_config.state.repeat_type in (
RepeatType.REPEAT_SONG): RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
):
has_next_song = True has_next_song = True
elif has_current_song: elif has_current_song:
has_next_song = ( last_idx_in_queue = len(app_config.state.play_queue) - 1
state.current_song_index < len(state.play_queue) - 1) has_next_song = app_config.state.current_song_index < last_idx_in_queue
# Toggle button states. # Toggle button states.
self.repeat_button.set_action_name(None) self.repeat_button.set_action_name(None)
self.shuffle_button.set_action_name(None) self.shuffle_button.set_action_name(None)
repeat_on = state.repeat_type in ( repeat_on = app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG) RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
)
self.repeat_button.set_active(repeat_on) self.repeat_button.set_active(repeat_on)
self.repeat_button.set_icon(state.repeat_type.icon) self.repeat_button.set_icon(app_config.state.repeat_type.icon)
self.shuffle_button.set_active(state.shuffle_on) self.shuffle_button.set_active(app_config.state.shuffle_on)
self.repeat_button.set_action_name('app.repeat-press') self.repeat_button.set_action_name("app.repeat-press")
self.shuffle_button.set_action_name('app.shuffle-press') self.shuffle_button.set_action_name("app.shuffle-press")
self.song_scrubber.set_sensitive(has_current_song) self.song_scrubber.set_sensitive(has_current_song)
self.prev_button.set_sensitive(has_current_song) self.prev_button.set_sensitive(has_current_song)
@@ -115,168 +116,192 @@ class PlayerControls(Gtk.ActionBar):
self.next_button.set_sensitive(has_current_song and has_next_song) self.next_button.set_sensitive(has_current_song and has_next_song)
# Volume button and slider # Volume button and slider
if state.is_muted: if app_config.state.is_muted:
icon_name = 'muted' icon_name = "muted"
elif state.volume < 30: elif app_config.state.volume < 30:
icon_name = 'low' icon_name = "low"
elif state.volume < 70: elif app_config.state.volume < 70:
icon_name = 'medium' icon_name = "medium"
else: else:
icon_name = 'high' icon_name = "high"
self.volume_mute_toggle.set_icon(f'audio-volume-{icon_name}-symbolic') self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
self.editing = True self.editing = True
self.volume_slider.set_value(0 if state.is_muted else state.volume) self.volume_slider.set_value(
0 if app_config.state.is_muted else app_config.state.volume
)
self.editing = False self.editing = False
# Update the current song information. # Update the current song information.
# TODO (#126): add popup of bigger cover art photo here # TODO (#126): add popup of bigger cover art photo here
if state.current_song is not None: if app_config.state.current_song is not None:
self.cover_art_update_order_token += 1 self.cover_art_update_order_token += 1
self.update_cover_art( self.update_cover_art(
state.current_song.coverArt, app_config.state.current_song.cover_art,
order_token=self.cover_art_update_order_token, order_token=self.cover_art_update_order_token,
) )
self.song_title.set_markup(util.esc(state.current_song.title)) self.song_title.set_markup(util.esc(app_config.state.current_song.title))
self.album_name.set_markup(util.esc(state.current_song.album)) # TODO (#71): use walrus once MYPY gets its act together
artist_name = util.esc(state.current_song.artist) album = app_config.state.current_song.album
self.artist_name.set_markup(artist_name or '') artist = app_config.state.current_song.artist
if album:
self.album_name.set_markup(util.esc(album.name))
self.artist_name.show()
else:
self.album_name.set_markup("")
self.album_name.hide()
if artist:
self.artist_name.set_markup(util.esc(artist.name))
self.artist_name.show()
else:
self.artist_name.set_markup("")
self.artist_name.hide()
else: else:
# Clear out the cover art and song tite if no song # Clear out the cover art and song tite if no song
self.album_art.set_from_file(None) self.album_art.set_from_file(None)
self.album_art.set_loading(False) self.album_art.set_loading(False)
self.song_title.set_markup('') self.song_title.set_markup("")
self.album_name.set_markup('') self.album_name.set_markup("")
self.artist_name.set_markup('') self.artist_name.set_markup("")
if self.devices_requested: if self.devices_requested:
self.update_device_list() self.update_device_list()
# Short circuit if no changes to the play queue
if (
self.current_play_queue == app_config.state.play_queue
and self.current_playing_index == app_config.state.current_song_index
):
return
self.current_play_queue = app_config.state.play_queue
self.current_playing_index = app_config.state.current_song_index
# Set the Play Queue button popup. # Set the Play Queue button popup.
if hasattr(state, 'play_queue'): play_queue_len = len(app_config.state.play_queue)
play_queue_len = len(state.play_queue) if play_queue_len == 0:
if play_queue_len == 0: self.popover_label.set_markup("<b>Play Queue</b>")
self.popover_label.set_markup('<b>Play Queue</b>') else:
else: song_label = util.pluralize("song", play_queue_len)
song_label = util.pluralize('song', play_queue_len) self.popover_label.set_markup(
self.popover_label.set_markup( f"<b>Play Queue:</b> {play_queue_len} {song_label}"
f'<b>Play Queue:</b> {play_queue_len} {song_label}') )
self.editing_play_queue_song_list = True # TODO (#207) this is super freaking stupid inefficient.
# IDEAS: batch it, don't get the queue until requested
self.editing_play_queue_song_list = True
new_store = [] new_store = []
def calculate_label(song_details: Child) -> str: def calculate_label(song_details: Song) -> str:
title = util.esc(song_details.title) title = util.esc(song_details.title)
album = util.esc(song_details.album) # TODO (#71): use walrus once MYPY works with this
artist = util.esc(song_details.artist) # album = util.esc(album.name if (album := song_details.album) else None)
return f'<b>{title}</b>\n{util.dot_join(album, artist)}' # artist = util.esc(artist.name if (artist := song_details.artist) else None) # noqa
album = util.esc(song_details.album.name if song_details.album else None)
artist = util.esc(song_details.artist.name if song_details.artist else None)
return f"<b>{title}</b>\n{util.dot_join(album, artist)}"
def make_idle_index_capturing_function( def make_idle_index_capturing_function(
idx: int, idx: int, order_tok: int, fn: Callable[[int, int, Any], None],
order_tok: int, ) -> Callable[[Result], None]:
fn: Callable[[int, int, Any], None], return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
) -> Callable[[CacheManager.Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
def on_cover_art_future_done( def on_cover_art_future_done(
idx: int, idx: int, order_token: int, cover_art_filename: str,
order_token: int, ):
cover_art_filename: str, if order_token != self.play_queue_update_order_token:
): return
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][0] = cover_art_filename self.play_queue_store[idx][0] = cover_art_filename
def on_song_details_future_done( def get_cover_art_filename_or_create_future(
idx: int, cover_art_id: Optional[str], idx: int, order_token: int
order_token: int, ) -> Optional[str]:
song_details: Child, cover_art_result = AdapterManager.get_cover_art_filename(cover_art_id)
): if not cover_art_result.data_is_available:
if order_token != self.play_queue_update_order_token: cover_art_result.add_done_callback(
return
self.play_queue_store[idx][1] = calculate_label(song_details)
# Cover Art
cover_art_result = CacheManager.get_cover_art_filename(
song_details.coverArt)
if cover_art_result.is_future:
# We don't have the cover art already cached.
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
idx,
order_token,
on_cover_art_future_done,
))
else:
# We have the cover art already cached.
self.play_queue_store[idx][0] = cover_art_result.result()
if state.play_queue != [x[-1] for x in self.play_queue_store]:
self.play_queue_update_order_token += 1
song_details_results = []
for i, song_id in enumerate(state.play_queue):
song_details_result = CacheManager.get_song_details(song_id)
cover_art_filename = ''
label = '\n'
if song_details_result.is_future:
song_details_results.append((i, song_details_result))
else:
# We have the details of the song already cached.
song_details = song_details_result.result()
label = calculate_label(song_details)
cover_art_result = CacheManager.get_cover_art_filename(
song_details.coverArt)
if cover_art_result.is_future:
# We don't have the cover art already cached.
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
i,
self.play_queue_update_order_token,
on_cover_art_future_done,
))
else:
# We have the cover art already cached.
cover_art_filename = cover_art_result.result()
new_store.append(
[
cover_art_filename,
label,
i == state.current_song_index,
song_id,
])
util.diff_song_store(self.play_queue_store, new_store)
# Do this after the diff to avoid race conditions.
for idx, song_details_result in song_details_results:
song_details_result.add_done_callback(
make_idle_index_capturing_function( make_idle_index_capturing_function(
idx, idx, order_token, on_cover_art_future_done
self.play_queue_update_order_token, )
on_song_details_future_done, )
)) return None
self.editing_play_queue_song_list = False # The cover art is already cached.
return cover_art_result.result()
def on_song_details_future_done(
idx: int, order_token: int, song_details: Song,
):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][1] = calculate_label(song_details)
# Cover Art
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, idx, order_token
)
if filename:
self.play_queue_store[idx][0] = filename
current_play_queue = [x[-1] for x in self.play_queue_store]
if app_config.state.play_queue != current_play_queue:
self.play_queue_update_order_token += 1
song_details_results = []
for i, song_id in enumerate(app_config.state.play_queue):
song_details_result = AdapterManager.get_song_details(song_id)
cover_art_filename = ""
label = "\n"
if song_details_result.data_is_available:
# We have the details of the song already cached.
song_details = song_details_result.result()
label = calculate_label(song_details)
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, i, self.play_queue_update_order_token
)
if filename:
cover_art_filename = filename
else:
song_details_results.append((i, song_details_result))
new_store.append(
[
cover_art_filename,
label,
i == app_config.state.current_song_index,
song_id,
]
)
util.diff_song_store(self.play_queue_store, new_store)
# Do this after the diff to avoid race conditions.
for idx, song_details_result in song_details_results:
song_details_result.add_done_callback(
make_idle_index_capturing_function(
idx,
self.play_queue_update_order_token,
on_song_details_future_done,
)
)
self.editing_play_queue_song_list = False
@util.async_callback( @util.async_callback(
lambda *k, **v: CacheManager.get_cover_art_filename(*k, **v), AdapterManager.get_cover_art_filename,
before_download=lambda self: self.album_art.set_loading(True), before_download=lambda self: self.album_art.set_loading(True),
on_failure=lambda self, e: self.album_art.set_loading(False), on_failure=lambda self, e: self.album_art.set_loading(False),
) )
def update_cover_art( def update_cover_art(
self, self,
cover_art_filename: str, cover_art_filename: str,
state: ApplicationState, app_config: AppConfiguration,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
): ):
@@ -286,25 +311,36 @@ class PlayerControls(Gtk.ActionBar):
self.album_art.set_from_file(cover_art_filename) self.album_art.set_from_file(cover_art_filename)
self.album_art.set_loading(False) self.album_art.set_loading(False)
def update_scrubber(self, current: float, duration: int): def update_scrubber(
self,
current: Optional[timedelta],
duration: Optional[timedelta],
song_stream_cache_progress: Optional[timedelta],
):
if current is None or duration is None: if current is None or duration is None:
self.song_duration_label.set_text('-:--') self.song_duration_label.set_text("-:--")
self.song_progress_label.set_text('-:--') self.song_progress_label.set_text("-:--")
self.song_scrubber.set_value(0) self.song_scrubber.set_value(0)
return return
current = current or 0
percent_complete = current / duration * 100 percent_complete = current / duration * 100
if not self.editing: if not self.editing:
self.song_scrubber.set_value(percent_complete) self.song_scrubber.set_value(percent_complete)
self.song_scrubber.set_show_fill_level(song_stream_cache_progress is not None)
if song_stream_cache_progress:
percent_cached = song_stream_cache_progress / duration * 100
self.song_scrubber.set_fill_level(percent_cached)
self.song_duration_label.set_text(util.format_song_duration(duration)) self.song_duration_label.set_text(util.format_song_duration(duration))
self.song_progress_label.set_text( self.song_progress_label.set_text(
util.format_song_duration(math.floor(current))) util.format_song_duration(math.floor(current.total_seconds()))
)
def on_volume_change(self, scale: Gtk.Scale): def on_volume_change(self, scale: Gtk.Scale):
if not self.editing: if not self.editing:
self.emit('volume-change', scale.get_value()) self.emit("volume-change", scale.get_value())
def on_play_queue_click(self, _: Any): def on_play_queue_click(self, _: Any):
if self.play_queue_popover.is_visible(): if self.play_queue_popover.is_visible():
@@ -317,10 +353,10 @@ class PlayerControls(Gtk.ActionBar):
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any): def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.play_queue_store], [m[-1] for m in self.play_queue_store],
{'no_reshuffle': True}, {"no_reshuffle": True},
) )
def update_device_list(self, force: bool = False): def update_device_list(self, force: bool = False):
@@ -331,21 +367,23 @@ class PlayerControls(Gtk.ActionBar):
for c in self.chromecast_device_list.get_children(): for c in self.chromecast_device_list.get_children():
self.chromecast_device_list.remove(c) self.chromecast_device_list.remove(c)
if self.current_device == 'this device': if self.current_device == "this device":
self.this_device.set_icon('audio-volume-high-symbolic') self.this_device.set_icon("audio-volume-high-symbolic")
else: else:
self.this_device.set_icon(None) self.this_device.set_icon(None)
chromecasts.sort(key=lambda c: c.device.friendly_name) chromecasts.sort(key=lambda c: c.device.friendly_name)
for cc in chromecasts: for cc in chromecasts:
icon = ( icon = (
'audio-volume-high-symbolic' "audio-volume-high-symbolic"
if str(cc.device.uuid) == self.current_device else None) if str(cc.device.uuid) == self.current_device
else None
)
btn = IconButton(icon, label=cc.device.friendly_name) btn = IconButton(icon, label=cc.device.friendly_name)
btn.get_style_context().add_class('menu-button') btn.get_style_context().add_class("menu-button")
btn.connect( btn.connect(
'clicked', "clicked",
lambda _, uuid: self.emit('device-update', uuid), lambda _, uuid: self.emit("device-update", uuid),
cc.device.uuid, cc.device.uuid,
) )
self.chromecast_device_list.add(btn) self.chromecast_device_list.add(btn)
@@ -356,12 +394,13 @@ class PlayerControls(Gtk.ActionBar):
update_diff = ( update_diff = (
self.last_device_list_update self.last_device_list_update
and (datetime.now() - self.last_device_list_update).seconds > 60) and (datetime.now() - self.last_device_list_update).seconds > 60
if (force or len(self.chromecasts) == 0 )
or (update_diff and update_diff > 60)): if force or len(self.chromecasts) == 0 or (update_diff and update_diff > 60):
future = ChromecastPlayer.get_chromecasts() future = ChromecastPlayer.get_chromecasts()
future.add_done_callback( future.add_done_callback(
lambda f: GLib.idle_add(chromecast_callback, f.result())) lambda f: GLib.idle_add(chromecast_callback, f.result())
)
else: else:
chromecast_callback(self.chromecasts) chromecast_callback(self.chromecasts)
@@ -377,21 +416,17 @@ class PlayerControls(Gtk.ActionBar):
def on_device_refresh_click(self, _: Any): def on_device_refresh_click(self, _: Any):
self.update_device_list(force=True) self.update_device_list(force=True)
def on_play_queue_button_press( def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton,) -> bool:
self,
tree: Any,
event: Gdk.EventButton,
) -> bool:
if event.button == 3: # Right click if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y) clicked_path = tree.get_path_at_pos(event.x, event.y)
store, paths = tree.get_selection().get_selected_rows() store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False allow_deselect = False
def on_download_state_change(): def on_download_state_change(song_id: str):
# Refresh the entire window (no force) because the song could # Refresh the entire window (no force) because the song could
# be in a list anywhere in the window. # be in a list anywhere in the window.
self.emit('refresh-window', {}, False) self.emit("refresh-window", {}, False)
# Use the new selection instead of the old one for calculating what # Use the new selection instead of the old one for calculating what
# to do the right click on. # to do the right click on.
@@ -402,11 +437,11 @@ class PlayerControls(Gtk.ActionBar):
song_ids = [self.play_queue_store[p][-1] for p in paths] song_ids = [self.play_queue_store[p][-1] for p in paths]
remove_text = ( remove_text = (
'Remove ' + util.pluralize('song', len(song_ids)) "Remove " + util.pluralize("song", len(song_ids)) + " from queue"
+ ' from queue') )
def on_remove_songs_click(_: Any): def on_remove_songs_click(_: Any):
self.emit('songs-removed', [p.get_indices()[0] for p in paths]) self.emit("songs-removed", [p.get_indices()[0] for p in paths])
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
@@ -438,10 +473,10 @@ class PlayerControls(Gtk.ActionBar):
i for i, s in enumerate(self.play_queue_store) if s[2] i for i, s in enumerate(self.play_queue_store) if s[2]
][0] ][0]
self.emit( self.emit(
'refresh-window', "refresh-window",
{ {
'current_song_index': currently_playing_index, "current_song_index": currently_playing_index,
'play_queue': [s[-1] for s in self.play_queue_store], "play_queue": tuple(s[-1] for s in self.play_queue_store),
}, },
False, False,
) )
@@ -453,8 +488,7 @@ class PlayerControls(Gtk.ActionBar):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.album_art = SpinnerImage( self.album_art = SpinnerImage(
image_name='player-controls-album-artwork', image_name="player-controls-album-artwork", image_size=70,
image_size=70,
) )
box.pack_start(self.album_art, False, False, 5) box.pack_start(self.album_art, False, False, 5)
@@ -470,13 +504,13 @@ class PlayerControls(Gtk.ActionBar):
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
) )
self.song_title = make_label('song-title') self.song_title = make_label("song-title")
details_box.add(self.song_title) details_box.add(self.song_title)
self.album_name = make_label('album-name') self.album_name = make_label("album-name")
details_box.add(self.album_name) details_box.add(self.album_name)
self.artist_name = make_label('artist-name') self.artist_name = make_label("artist-name")
details_box.add(self.artist_name) details_box.add(self.artist_name)
details_box.pack_start(Gtk.Box(), True, True, 0) details_box.pack_start(Gtk.Box(), True, True, 0)
@@ -490,18 +524,21 @@ class PlayerControls(Gtk.ActionBar):
# Scrubber and song progress/length labels # Scrubber and song progress/length labels
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.song_progress_label = Gtk.Label(label='-:--') self.song_progress_label = Gtk.Label(label="-:--")
scrubber_box.pack_start(self.song_progress_label, False, False, 5) scrubber_box.pack_start(self.song_progress_label, False, False, 5)
self.song_scrubber = Gtk.Scale.new_with_range( self.song_scrubber = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5) orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
self.song_scrubber.set_name('song-scrubber') )
self.song_scrubber.set_name("song-scrubber")
self.song_scrubber.set_draw_value(False) self.song_scrubber.set_draw_value(False)
self.song_scrubber.set_restrict_to_fill_level(False)
self.song_scrubber.connect( self.song_scrubber.connect(
'change-value', lambda s, t, v: self.emit('song-scrub', v)) "change-value", lambda s, t, v: self.emit("song-scrub", v)
)
scrubber_box.pack_start(self.song_scrubber, True, True, 0) scrubber_box.pack_start(self.song_scrubber, True, True, 0)
self.song_duration_label = Gtk.Label(label='-:--') self.song_duration_label = Gtk.Label(label="-:--")
scrubber_box.pack_start(self.song_duration_label, False, False, 5) scrubber_box.pack_start(self.song_duration_label, False, False, 5)
box.add(scrubber_box) box.add(scrubber_box)
@@ -512,8 +549,9 @@ class PlayerControls(Gtk.ActionBar):
# Repeat button # Repeat button
repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.repeat_button = IconToggleButton( self.repeat_button = IconToggleButton(
'media-playlist-repeat', 'Switch between repeat modes') "media-playlist-repeat", "Switch between repeat modes"
self.repeat_button.set_action_name('app.repeat-press') )
self.repeat_button.set_action_name("app.repeat-press")
repeat_button_box.pack_start(Gtk.Box(), True, True, 0) repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
repeat_button_box.pack_start(self.repeat_button, False, False, 0) repeat_button_box.pack_start(self.repeat_button, False, False, 0)
repeat_button_box.pack_start(Gtk.Box(), True, True, 0) repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
@@ -521,35 +559,39 @@ class PlayerControls(Gtk.ActionBar):
# Previous button # Previous button
self.prev_button = IconButton( self.prev_button = IconButton(
'media-skip-backward-symbolic', "media-skip-backward-symbolic",
'Go to previous song', "Go to previous song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR) icon_size=Gtk.IconSize.LARGE_TOOLBAR,
self.prev_button.set_action_name('app.prev-track') )
self.prev_button.set_action_name("app.prev-track")
buttons.pack_start(self.prev_button, False, False, 5) buttons.pack_start(self.prev_button, False, False, 5)
# Play button # Play button
self.play_button = IconButton( self.play_button = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic",
'Play', "Play",
relief=True, relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR) icon_size=Gtk.IconSize.LARGE_TOOLBAR,
self.play_button.set_name('play-button') )
self.play_button.set_action_name('app.play-pause') self.play_button.set_name("play-button")
self.play_button.set_action_name("app.play-pause")
buttons.pack_start(self.play_button, False, False, 0) buttons.pack_start(self.play_button, False, False, 0)
# Next button # Next button
self.next_button = IconButton( self.next_button = IconButton(
'media-skip-forward-symbolic', "media-skip-forward-symbolic",
'Go to next song', "Go to next song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR) icon_size=Gtk.IconSize.LARGE_TOOLBAR,
self.next_button.set_action_name('app.next-track') )
self.next_button.set_action_name("app.next-track")
buttons.pack_start(self.next_button, False, False, 5) buttons.pack_start(self.next_button, False, False, 5)
# Shuffle button # Shuffle button
shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.shuffle_button = IconToggleButton( self.shuffle_button = IconToggleButton(
'media-playlist-shuffle-symbolic', 'Toggle playlist shuffling') "media-playlist-shuffle-symbolic", "Toggle playlist shuffling"
self.shuffle_button.set_action_name('app.shuffle-press') )
self.shuffle_button.set_action_name("app.shuffle-press")
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0) shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
shuffle_button_box.pack_start(self.shuffle_button, False, False, 0) shuffle_button_box.pack_start(self.shuffle_button, False, False, 0)
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0) shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
@@ -567,36 +609,28 @@ class PlayerControls(Gtk.ActionBar):
# Device button (for chromecast) # Device button (for chromecast)
self.device_button = IconButton( self.device_button = IconButton(
'video-display-symbolic', "video-display-symbolic",
'Show available audio output devices', "Show available audio output devices",
icon_size=Gtk.IconSize.LARGE_TOOLBAR, icon_size=Gtk.IconSize.LARGE_TOOLBAR,
) )
self.device_button.connect('clicked', self.on_device_click) self.device_button.connect("clicked", self.on_device_click)
box.pack_start(self.device_button, False, True, 5) box.pack_start(self.device_button, False, True, 5)
self.device_popover = Gtk.PopoverMenu( self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover",)
modal=False,
name='device-popover',
)
self.device_popover.set_relative_to(self.device_button) self.device_popover.set_relative_to(self.device_button)
device_popover_box = Gtk.Box( device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL, name="device-popover-box",
name='device-popover-box',
) )
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label( self.popover_label = Gtk.Label(
label='<b>Devices</b>', label="<b>Devices</b>", use_markup=True, halign=Gtk.Align.START, margin=5,
use_markup=True,
halign=Gtk.Align.START,
margin=5,
) )
device_popover_header.add(self.popover_label) device_popover_header.add(self.popover_label)
refresh_devices = IconButton( refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
'view-refresh-symbolic', 'Refresh device list') refresh_devices.connect("clicked", self.on_device_refresh_click)
refresh_devices.connect('clicked', self.on_device_refresh_click)
device_popover_header.pack_end(refresh_devices, False, False, 0) device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header) device_popover_box.add(device_popover_header)
@@ -604,22 +638,21 @@ class PlayerControls(Gtk.ActionBar):
device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.this_device = IconButton( self.this_device = IconButton(
'audio-volume-high-symbolic', "audio-volume-high-symbolic", label="This Device",
label='This Device',
) )
self.this_device.get_style_context().add_class('menu-button') self.this_device.get_style_context().add_class("menu-button")
self.this_device.connect( self.this_device.connect(
'clicked', lambda *a: self.emit('device-update', 'this device')) "clicked", lambda *a: self.emit("device-update", "this device")
)
device_list.add(self.this_device) device_list.add(self.this_device)
device_list.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) device_list.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
self.device_list_loading = Gtk.Spinner(active=True) self.device_list_loading = Gtk.Spinner(active=True)
self.device_list_loading.get_style_context().add_class('menu-button') self.device_list_loading.get_style_context().add_class("menu-button")
device_list.add(self.device_list_loading) device_list.add(self.device_list_loading)
self.chromecast_device_list = Gtk.Box( self.chromecast_device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
orientation=Gtk.Orientation.VERTICAL)
device_list.add(self.chromecast_device_list) device_list.add(self.chromecast_device_list)
device_popover_box.pack_end(device_list, True, True, 0) device_popover_box.pack_end(device_list, True, True, 0)
@@ -628,25 +661,21 @@ class PlayerControls(Gtk.ActionBar):
# Play Queue button # Play Queue button
self.play_queue_button = IconButton( self.play_queue_button = IconButton(
'view-list-symbolic', "view-list-symbolic",
'Open play queue', "Open play queue",
icon_size=Gtk.IconSize.LARGE_TOOLBAR, icon_size=Gtk.IconSize.LARGE_TOOLBAR,
) )
self.play_queue_button.connect('clicked', self.on_play_queue_click) self.play_queue_button.connect("clicked", self.on_play_queue_click)
box.pack_start(self.play_queue_button, False, True, 5) box.pack_start(self.play_queue_button, False, True, 5)
self.play_queue_popover = Gtk.PopoverMenu( self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover",)
modal=False,
name='up-next-popover',
)
self.play_queue_popover.set_relative_to(self.play_queue_button) self.play_queue_popover.set_relative_to(self.play_queue_button)
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
play_queue_popover_header = Gtk.Box( play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label( self.popover_label = Gtk.Label(
label='<b>Play Queue</b>', label="<b>Play Queue</b>",
use_markup=True, use_markup=True,
halign=Gtk.Align.START, halign=Gtk.Align.START,
margin=10, margin=10,
@@ -654,15 +683,15 @@ class PlayerControls(Gtk.ActionBar):
play_queue_popover_header.add(self.popover_label) play_queue_popover_header.add(self.popover_label)
load_play_queue = IconButton( load_play_queue = IconButton(
'folder-download-symbolic', 'Load Queue from Server', margin=5) "folder-download-symbolic", "Load Queue from Server", margin=5
load_play_queue.set_action_name('app.update-play-queue-from-server') )
load_play_queue.set_action_name("app.update-play-queue-from-server")
play_queue_popover_header.pack_end(load_play_queue, False, False, 0) play_queue_popover_header.pack_end(load_play_queue, False, False, 0)
play_queue_popover_box.add(play_queue_popover_header) play_queue_popover_box.add(play_queue_popover_header)
play_queue_scrollbox = Gtk.ScrolledWindow( play_queue_scrollbox = Gtk.ScrolledWindow(
min_content_height=600, min_content_height=600, min_content_width=400,
min_content_width=400,
) )
self.play_queue_store = Gtk.ListStore( self.play_queue_store = Gtk.ListStore(
@@ -672,12 +701,9 @@ class PlayerControls(Gtk.ActionBar):
str, # song ID str, # song ID
) )
self.play_queue_list = Gtk.TreeView( self.play_queue_list = Gtk.TreeView(
model=self.play_queue_store, model=self.play_queue_store, reorderable=True, headers_visible=False,
reorderable=True,
headers_visible=False,
) )
self.play_queue_list.get_selection().set_mode( self.play_queue_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
Gtk.SelectionMode.MULTIPLE)
# Album Art column. # Album Art column.
def filename_to_pixbuf( def filename_to_pixbuf(
@@ -689,48 +715,42 @@ class PlayerControls(Gtk.ActionBar):
): ):
filename = model.get_value(iter, 0) filename = model.get_value(iter, 0)
if not filename: if not filename:
cell.set_property('icon_name', '') cell.set_property("icon_name", "")
return return
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
filename, 50, 50, True)
# If this is the playing song, then overlay the play icon. # If this is the playing song, then overlay the play icon.
if model.get_value(iter, 2): if model.get_value(iter, 2):
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file( play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
str( str(Path(__file__).parent.joinpath("images/play-queue-play.png"))
Path(__file__).parent.joinpath( )
'images/play-queue-play.png')))
play_overlay_pixbuf.composite( play_overlay_pixbuf.composite(
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 255
GdkPixbuf.InterpType.NEAREST, 255) )
cell.set_property('pixbuf', pixbuf) cell.set_property("pixbuf", pixbuf)
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(55, 60) renderer.set_fixed_size(55, 60)
column = Gtk.TreeViewColumn('', renderer) column = Gtk.TreeViewColumn("", renderer)
column.set_cell_data_func(renderer, filename_to_pixbuf) column.set_cell_data_func(renderer, filename_to_pixbuf)
column.set_resizable(True) column.set_resizable(True)
self.play_queue_list.append_column(column) self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererText( renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END,)
markup=True, column = Gtk.TreeViewColumn("", renderer, markup=1)
ellipsize=Pango.EllipsizeMode.END,
)
column = Gtk.TreeViewColumn('', renderer, markup=1)
self.play_queue_list.append_column(column) self.play_queue_list.append_column(column)
self.play_queue_list.connect('row-activated', self.on_song_activated) self.play_queue_list.connect("row-activated", self.on_song_activated)
self.play_queue_list.connect( self.play_queue_list.connect(
'button-press-event', self.on_play_queue_button_press) "button-press-event", self.on_play_queue_button_press
)
# Set up drag-and-drop on the song list for editing the order of the # Set up drag-and-drop on the song list for editing the order of the
# playlist. # playlist.
self.play_queue_store.connect( self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
'row-inserted', self.on_play_queue_model_row_move) self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
self.play_queue_store.connect(
'row-deleted', self.on_play_queue_model_row_move)
play_queue_scrollbox.add(self.play_queue_list) play_queue_scrollbox.add(self.play_queue_list)
play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0) play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0)
@@ -739,16 +759,18 @@ class PlayerControls(Gtk.ActionBar):
# Volume mute toggle # Volume mute toggle
self.volume_mute_toggle = IconButton( self.volume_mute_toggle = IconButton(
'audio-volume-high-symbolic', 'Toggle mute') "audio-volume-high-symbolic", "Toggle mute"
self.volume_mute_toggle.set_action_name('app.mute-toggle') )
self.volume_mute_toggle.set_action_name("app.mute-toggle")
box.pack_start(self.volume_mute_toggle, False, True, 0) box.pack_start(self.volume_mute_toggle, False, True, 0)
# Volume slider # Volume slider
self.volume_slider = Gtk.Scale.new_with_range( self.volume_slider = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5) orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
self.volume_slider.set_name('volume-slider') )
self.volume_slider.set_name("volume-slider")
self.volume_slider.set_draw_value(False) self.volume_slider.set_draw_value(False)
self.volume_slider.connect('value-changed', self.on_volume_change) self.volume_slider.connect("value-changed", self.on_volume_change)
box.pack_start(self.volume_slider, True, True, 0) box.pack_start(self.volume_slider, True, True, 0)
vbox.pack_start(box, False, True, 0) vbox.pack_start(box, False, True, 0)

View File

@@ -1,15 +1,12 @@
from functools import lru_cache from functools import lru_cache
from random import randint from random import randint
from typing import Any, Iterable, List, Tuple from typing import Any, cast, Iterable, List, Tuple
import gi
gi.require_version('Gtk', '3.0')
from fuzzywuzzy import process from fuzzywuzzy import process
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager from sublime.adapters import AdapterManager, api_objects as API
from sublime.server.api_objects import PlaylistWithSongs from sublime.config import AppConfiguration
from sublime.state_manager import ApplicationState
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import ( from sublime.ui.common import (
EditFormDialog, EditFormDialog,
@@ -20,26 +17,27 @@ from sublime.ui.common import (
class EditPlaylistDialog(EditFormDialog): class EditPlaylistDialog(EditFormDialog):
entity_name: str = 'Playlist' entity_name: str = "Playlist"
initial_size = (350, 120) initial_size = (350, 120)
text_fields = [('Name', 'name', False), ('Comment', 'comment', False)] text_fields = [("Name", "name", False), ("Comment", "comment", False)]
boolean_fields = [('Public', 'public')] boolean_fields = [("Public", "public")]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
delete_playlist = Gtk.Button(label='Delete Playlist') delete_playlist = Gtk.Button(label="Delete Playlist")
self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)] self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)]
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class PlaylistsPanel(Gtk.Paned): class PlaylistsPanel(Gtk.Paned):
"""Defines the playlists panel.""" """Defines the playlists panel."""
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -54,23 +52,21 @@ class PlaylistsPanel(Gtk.Paned):
self.playlist_detail_panel = PlaylistDetailPanel() self.playlist_detail_panel = PlaylistDetailPanel()
self.playlist_detail_panel.connect( self.playlist_detail_panel.connect(
'song-clicked', "song-clicked", lambda _, *args: self.emit("song-clicked", *args),
lambda _, *args: self.emit('song-clicked', *args),
) )
self.playlist_detail_panel.connect( self.playlist_detail_panel.connect(
'refresh-window', "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
lambda _, *args: self.emit('refresh-window', *args),
) )
self.pack2(self.playlist_detail_panel, True, False) self.pack2(self.playlist_detail_panel, True, False)
def update(self, state: ApplicationState = None, force: bool = False): def update(self, app_config: AppConfiguration = None, force: bool = False):
self.playlist_list.update(state=state, force=force) self.playlist_list.update(app_config=app_config, force=force)
self.playlist_detail_panel.update(state=state, force=force) self.playlist_detail_panel.update(app_config=app_config, force=force)
class PlaylistList(Gtk.Box): class PlaylistList(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -91,57 +87,50 @@ class PlaylistList(Gtk.Box):
playlist_list_actions = Gtk.ActionBar() playlist_list_actions = Gtk.ActionBar()
new_playlist_button = IconButton( new_playlist_button = IconButton("list-add-symbolic", label="New Playlist")
'list-add-symbolic', label='New Playlist') new_playlist_button.connect("clicked", self.on_new_playlist_clicked)
new_playlist_button.connect('clicked', self.on_new_playlist_clicked)
playlist_list_actions.pack_start(new_playlist_button) playlist_list_actions.pack_start(new_playlist_button)
list_refresh_button = IconButton( list_refresh_button = IconButton(
'view-refresh-symbolic', 'Refresh list of playlists') "view-refresh-symbolic", "Refresh list of playlists"
list_refresh_button.connect('clicked', self.on_list_refresh_click) )
list_refresh_button.connect("clicked", self.on_list_refresh_click)
playlist_list_actions.pack_end(list_refresh_button) playlist_list_actions.pack_end(list_refresh_button)
self.add(playlist_list_actions) self.add(playlist_list_actions)
loading_new_playlist = Gtk.ListBox() loading_new_playlist = Gtk.ListBox()
self.loading_indicator = Gtk.ListBoxRow( self.loading_indicator = Gtk.ListBoxRow(activatable=False, selectable=False,)
activatable=False, loading_spinner = Gtk.Spinner(name="playlist-list-spinner", active=True)
selectable=False,
)
loading_spinner = Gtk.Spinner(
name='playlist-list-spinner', active=True)
self.loading_indicator.add(loading_spinner) self.loading_indicator.add(loading_spinner)
loading_new_playlist.add(self.loading_indicator) loading_new_playlist.add(self.loading_indicator)
self.new_playlist_row = Gtk.ListBoxRow( self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False)
activatable=False, selectable=False) new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False)
new_playlist_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, visible=False)
self.new_playlist_entry = Gtk.Entry( self.new_playlist_entry = Gtk.Entry(name="playlist-list-new-playlist-entry")
name='playlist-list-new-playlist-entry') self.new_playlist_entry.connect("activate", self.new_entry_activate)
self.new_playlist_entry.connect('activate', self.new_entry_activate)
new_playlist_box.add(self.new_playlist_entry) new_playlist_box.add(self.new_playlist_entry)
new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
confirm_button = IconButton( confirm_button = IconButton(
'object-select-symbolic', "object-select-symbolic",
'Create playlist', "Create playlist",
name='playlist-list-new-playlist-confirm', name="playlist-list-new-playlist-confirm",
relief=True, relief=True,
) )
confirm_button.connect('clicked', self.confirm_button_clicked) confirm_button.connect("clicked", self.confirm_button_clicked)
new_playlist_actions.pack_end(confirm_button, False, True, 0) new_playlist_actions.pack_end(confirm_button, False, True, 0)
self.cancel_button = IconButton( self.cancel_button = IconButton(
'process-stop-symbolic', "process-stop-symbolic",
'Cancel create playlist', "Cancel create playlist",
name='playlist-list-new-playlist-cancel', name="playlist-list-new-playlist-cancel",
relief=True, relief=True,
) )
self.cancel_button.connect('clicked', self.cancel_button_clicked) self.cancel_button.connect("clicked", self.cancel_button_clicked)
new_playlist_actions.pack_end(self.cancel_button, False, True, 0) new_playlist_actions.pack_end(self.cancel_button, False, True, 0)
new_playlist_box.add(new_playlist_actions) new_playlist_box.add(new_playlist_actions)
@@ -152,26 +141,26 @@ class PlaylistList(Gtk.Box):
list_scroll_window = Gtk.ScrolledWindow(min_content_width=220) list_scroll_window = Gtk.ScrolledWindow(min_content_width=220)
def create_playlist_row( def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow( row = Gtk.ListBoxRow(
action_name='app.go-to-playlist', action_name="app.go-to-playlist",
action_target=GLib.Variant('s', model.playlist_id), action_target=GLib.Variant("s", model.playlist_id),
) )
row.add( row.add(
Gtk.Label( Gtk.Label(
label=f'<b>{model.name}</b>', label=f"<b>{model.name}</b>",
use_markup=True, use_markup=True,
margin=10, margin=10,
halign=Gtk.Align.START, halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END, ellipsize=Pango.EllipsizeMode.END,
max_width_chars=30, max_width_chars=30,
)) )
)
row.show_all() row.show_all()
return row return row
self.playlists_store = Gio.ListStore() self.playlists_store = Gio.ListStore()
self.list = Gtk.ListBox(name='playlist-list-listbox') self.list = Gtk.ListBox(name="playlist-list-listbox")
self.list.bind_model(self.playlists_store, create_playlist_row) self.list.bind_model(self.playlists_store, create_playlist_row)
list_scroll_window.add(self.list) list_scroll_window.add(self.list)
self.pack_start(list_scroll_window, True, True, 0) self.pack_start(list_scroll_window, True, True, 0)
@@ -181,25 +170,28 @@ class PlaylistList(Gtk.Box):
self.update_list(**kwargs) self.update_list(**kwargs)
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_playlists(*a, **k), AdapterManager.get_playlists,
before_download=lambda self: self.loading_indicator.show_all(), before_download=lambda self: self.loading_indicator.show_all(),
on_failure=lambda self, e: self.loading_indicator.hide(), on_failure=lambda self, e: self.loading_indicator.hide(),
) )
def update_list( def update_list(
self, self,
playlists: List[PlaylistWithSongs], playlists: List[API.Playlist],
state: ApplicationState, app_config: AppConfiguration,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
): ):
new_store = [] new_store = []
selected_idx = None selected_idx = None
for i, playlist in enumerate(playlists or []): for i, playlist in enumerate(playlists or []):
if state and state.selected_playlist_id == playlist.id: if (
app_config
and app_config.state
and app_config.state.selected_playlist_id == playlist.id
):
selected_idx = i selected_idx = i
new_store.append( new_store.append(PlaylistList.PlaylistModel(playlist.id, playlist.name))
PlaylistList.PlaylistModel(playlist.id, playlist.name))
util.diff_model_store(self.playlists_store, new_store) util.diff_model_store(self.playlists_store, new_store)
@@ -213,7 +205,7 @@ class PlaylistList(Gtk.Box):
# Event Handlers # Event Handlers
# ========================================================================= # =========================================================================
def on_new_playlist_clicked(self, _: Any): def on_new_playlist_clicked(self, _: Any):
self.new_playlist_entry.set_text('Untitled Playlist') self.new_playlist_entry.set_text("Untitled Playlist")
self.new_playlist_entry.grab_focus() self.new_playlist_entry.grab_focus()
self.new_playlist_row.show() self.new_playlist_row.show()
@@ -231,24 +223,23 @@ class PlaylistList(Gtk.Box):
def create_playlist(self, playlist_name: str): def create_playlist(self, playlist_name: str):
def on_playlist_created(_: Any): def on_playlist_created(_: Any):
CacheManager.invalidate_playlists_cache()
self.update(force=True) self.update(force=True)
self.loading_indicator.show() self.loading_indicator.show()
playlist_ceate_future = CacheManager.create_playlist( playlist_ceate_future = AdapterManager.create_playlist(name=playlist_name)
name=playlist_name)
playlist_ceate_future.add_done_callback( playlist_ceate_future.add_done_callback(
lambda f: GLib.idle_add(on_playlist_created, f)) lambda f: GLib.idle_add(on_playlist_created, f)
)
class PlaylistDetailPanel(Gtk.Overlay): class PlaylistDetailPanel(Gtk.Overlay):
__gsignals__ = { __gsignals__ = {
'song-clicked': ( "song-clicked": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(int, object, object), (int, object, object),
), ),
'refresh-window': ( "refresh-window": (
GObject.SignalFlags.RUN_FIRST, GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
@@ -261,19 +252,18 @@ class PlaylistDetailPanel(Gtk.Overlay):
reordering_playlist_song_list: bool = False reordering_playlist_song_list: bool = False
def __init__(self): def __init__(self):
Gtk.Overlay.__init__(self, name='playlist-view-overlay') Gtk.Overlay.__init__(self, name="playlist-view-overlay")
playlist_view_scroll_window = Gtk.ScrolledWindow() playlist_view_scroll_window = Gtk.ScrolledWindow()
playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Playlist info panel # Playlist info panel
self.big_info_panel = Gtk.Box( self.big_info_panel = Gtk.Box(
name='playlist-info-panel', name="playlist-info-panel", orientation=Gtk.Orientation.HORIZONTAL,
orientation=Gtk.Orientation.HORIZONTAL,
) )
self.playlist_artwork = SpinnerImage( self.playlist_artwork = SpinnerImage(
image_name='playlist-album-artwork', image_name="playlist-album-artwork",
spinner_name='playlist-artwork-spinner', spinner_name="playlist-artwork-spinner",
image_size=200, image_size=200,
) )
self.big_info_panel.pack_start(self.playlist_artwork, False, False, 0) self.big_info_panel.pack_start(self.playlist_artwork, False, False, 0)
@@ -283,65 +273,57 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Action buttons (note we are packing end here, so we have to put them # Action buttons (note we are packing end here, so we have to put them
# in right-to-left). # in right-to-left).
self.playlist_action_buttons = Gtk.Box( self.playlist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
orientation=Gtk.Orientation.HORIZONTAL)
view_refresh_button = IconButton( view_refresh_button = IconButton(
'view-refresh-symbolic', 'Refresh playlist info') "view-refresh-symbolic", "Refresh playlist info"
view_refresh_button.connect('clicked', self.on_view_refresh_click) )
self.playlist_action_buttons.pack_end( view_refresh_button.connect("clicked", self.on_view_refresh_click)
view_refresh_button, False, False, 5) self.playlist_action_buttons.pack_end(view_refresh_button, False, False, 5)
playlist_edit_button = IconButton( playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
'document-edit-symbolic', 'Edit paylist') playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
playlist_edit_button.connect( self.playlist_action_buttons.pack_end(playlist_edit_button, False, False, 5)
'clicked', self.on_playlist_edit_button_click)
self.playlist_action_buttons.pack_end(
playlist_edit_button, False, False, 5)
download_all_button = IconButton( download_all_button = IconButton(
'folder-download-symbolic', 'Download all songs in the playlist') "folder-download-symbolic", "Download all songs in the playlist"
)
download_all_button.connect( download_all_button.connect(
'clicked', self.on_playlist_list_download_all_button_click) "clicked", self.on_playlist_list_download_all_button_click
self.playlist_action_buttons.pack_end( )
download_all_button, False, False, 5) self.playlist_action_buttons.pack_end(download_all_button, False, False, 5)
playlist_details_box.pack_start( playlist_details_box.pack_start(self.playlist_action_buttons, False, False, 5)
self.playlist_action_buttons, False, False, 5)
playlist_details_box.pack_start(Gtk.Box(), True, False, 0) playlist_details_box.pack_start(Gtk.Box(), True, False, 0)
self.playlist_indicator = self.make_label(name='playlist-indicator') self.playlist_indicator = self.make_label(name="playlist-indicator")
playlist_details_box.add(self.playlist_indicator) playlist_details_box.add(self.playlist_indicator)
self.playlist_name = self.make_label(name='playlist-name') self.playlist_name = self.make_label(name="playlist-name")
playlist_details_box.add(self.playlist_name) playlist_details_box.add(self.playlist_name)
self.playlist_comment = self.make_label(name='playlist-comment') self.playlist_comment = self.make_label(name="playlist-comment")
playlist_details_box.add(self.playlist_comment) playlist_details_box.add(self.playlist_comment)
self.playlist_stats = self.make_label(name='playlist-stats') self.playlist_stats = self.make_label(name="playlist-stats")
playlist_details_box.add(self.playlist_stats) playlist_details_box.add(self.playlist_stats)
self.play_shuffle_buttons = Gtk.Box( self.play_shuffle_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, orientation=Gtk.Orientation.HORIZONTAL,
name='playlist-play-shuffle-buttons', name="playlist-play-shuffle-buttons",
) )
play_button = IconButton( play_button = IconButton(
'media-playback-start-symbolic', "media-playback-start-symbolic", label="Play All", relief=True,
label='Play All',
relief=True,
) )
play_button.connect('clicked', self.on_play_all_clicked) play_button.connect("clicked", self.on_play_all_clicked)
self.play_shuffle_buttons.pack_start(play_button, False, False, 0) self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
shuffle_button = IconButton( shuffle_button = IconButton(
'media-playlist-shuffle-symbolic', "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True,
label='Shuffle All',
relief=True,
) )
shuffle_button.connect('clicked', self.on_shuffle_all_button) shuffle_button.connect("clicked", self.on_shuffle_all_button)
self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
playlist_details_box.add(self.play_shuffle_buttons) playlist_details_box.add(self.play_shuffle_buttons)
@@ -369,17 +351,16 @@ class PlaylistDetailPanel(Gtk.Overlay):
return max(row_score(key, row) for row in rows) return max(row_score(key, row) for row in rows)
def playlist_song_list_search_fn( def playlist_song_list_search_fn(
model: Gtk.ListStore, model: Gtk.ListStore,
col: int, col: int,
key: str, key: str,
treeiter: Gtk.TreeIter, treeiter: Gtk.TreeIter,
data: Any = None, data: Any = None,
) -> bool: ) -> bool:
# TODO (#28): this is very inefficient, it's slow when the result # TODO (#28): this is very inefficient, it's slow when the result
# is close to the bottom of the list. Would be good to research # is close to the bottom of the list. Would be good to research
# what the default one does (maybe it uses an index?). # what the default one does (maybe it uses an index?).
max_score = max_score_for_key( max_score = max_score_for_key(key, tuple(tuple(row[1:4]) for row in model))
key, tuple(tuple(row[1:4]) for row in model))
row_max_score = row_score(key, tuple(model[treeiter][1:4])) row_max_score = row_score(key, tuple(model[treeiter][1:4]))
if row_max_score == max_score: if row_max_score == max_score:
return False # indicates match return False # indicates match
@@ -392,33 +373,31 @@ class PlaylistDetailPanel(Gtk.Overlay):
enable_search=True, enable_search=True,
) )
self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn) self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn)
self.playlist_songs.get_selection().set_mode( self.playlist_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
Gtk.SelectionMode.MULTIPLE)
# Song status column. # Song status column.
renderer = Gtk.CellRendererPixbuf() renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35) renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0) column = Gtk.TreeViewColumn("", renderer, icon_name=0)
column.set_resizable(True) column.set_resizable(True)
self.playlist_songs.append_column(column) self.playlist_songs.append_column(column)
self.playlist_songs.append_column(SongListColumn("TITLE", 1, bold=True))
self.playlist_songs.append_column(SongListColumn("ALBUM", 2))
self.playlist_songs.append_column(SongListColumn("ARTIST", 3))
self.playlist_songs.append_column( self.playlist_songs.append_column(
SongListColumn('TITLE', 1, bold=True)) SongListColumn("DURATION", 4, align=1, width=40)
self.playlist_songs.append_column(SongListColumn('ALBUM', 2)) )
self.playlist_songs.append_column(SongListColumn('ARTIST', 3))
self.playlist_songs.append_column(
SongListColumn('DURATION', 4, align=1, width=40))
self.playlist_songs.connect('row-activated', self.on_song_activated) self.playlist_songs.connect("row-activated", self.on_song_activated)
self.playlist_songs.connect( self.playlist_songs.connect("button-press-event", self.on_song_button_press)
'button-press-event', self.on_song_button_press)
# Set up drag-and-drop on the song list for editing the order of the # Set up drag-and-drop on the song list for editing the order of the
# playlist. # playlist.
self.playlist_song_store.connect( self.playlist_song_store.connect(
'row-inserted', self.on_playlist_model_row_move) "row-inserted", self.on_playlist_model_row_move
self.playlist_song_store.connect( )
'row-deleted', self.on_playlist_model_row_move) self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
playlist_box.add(self.playlist_songs) playlist_box.add(self.playlist_songs)
@@ -429,23 +408,20 @@ class PlaylistDetailPanel(Gtk.Overlay):
playlist_view_spinner.start() playlist_view_spinner.start()
self.playlist_view_loading_box = Gtk.Alignment( self.playlist_view_loading_box = Gtk.Alignment(
name='playlist-view-overlay', name="playlist-view-overlay", xalign=0.5, yalign=0.5, xscale=0.1, yscale=0.1
xalign=0.5, )
yalign=0.5,
xscale=0.1,
yscale=0.1)
self.playlist_view_loading_box.add(playlist_view_spinner) self.playlist_view_loading_box.add(playlist_view_spinner)
self.add_overlay(self.playlist_view_loading_box) self.add_overlay(self.playlist_view_loading_box)
update_playlist_view_order_token = 0 update_playlist_view_order_token = 0
def update(self, state: ApplicationState, force: bool = False): def update(self, app_config: AppConfiguration, force: bool = False):
if state.selected_playlist_id is None: if app_config.state.selected_playlist_id is None:
self.playlist_artwork.set_from_file(None) self.playlist_artwork.set_from_file(None)
self.playlist_indicator.set_markup('') self.playlist_indicator.set_markup("")
self.playlist_name.set_markup('') self.playlist_name.set_markup("")
self.playlist_comment.hide() self.playlist_comment.hide()
self.playlist_stats.set_markup('') self.playlist_stats.set_markup("")
self.playlist_action_buttons.hide() self.playlist_action_buttons.hide()
self.play_shuffle_buttons.hide() self.play_shuffle_buttons.hide()
self.playlist_view_loading_box.hide() self.playlist_view_loading_box.hide()
@@ -453,21 +429,23 @@ class PlaylistDetailPanel(Gtk.Overlay):
else: else:
self.update_playlist_view_order_token += 1 self.update_playlist_view_order_token += 1
self.update_playlist_view( self.update_playlist_view(
state.selected_playlist_id, app_config.state.selected_playlist_id,
state=state, app_config=app_config,
force=force, force=force,
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
) )
_current_song_ids: List[str] = []
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_playlist(*a, **k), AdapterManager.get_playlist_details,
before_download=lambda self: self.show_loading_all(), before_download=lambda self: self.show_loading_all(),
on_failure=lambda self, e: self.playlist_view_loading_box.hide(), on_failure=lambda self, e: self.playlist_view_loading_box.hide(),
) )
def update_playlist_view( def update_playlist_view(
self, self,
playlist: PlaylistWithSongs, playlist: API.Playlist,
state: ApplicationState = None, app_config: AppConfiguration = None,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
): ):
@@ -482,8 +460,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_id = playlist.id self.playlist_id = playlist.id
# Update the info display. # Update the info display.
self.playlist_indicator.set_markup('PLAYLIST') self.playlist_indicator.set_markup("PLAYLIST")
self.playlist_name.set_markup(f'<b>{playlist.name}</b>') self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
if playlist.comment: if playlist.comment:
self.playlist_comment.set_text(playlist.comment) self.playlist_comment.set_text(playlist.comment)
self.playlist_comment.show() self.playlist_comment.show()
@@ -492,28 +470,53 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_stats.set_markup(self._format_stats(playlist)) self.playlist_stats.set_markup(self._format_stats(playlist))
# Update the artwork. # Update the artwork.
self.update_playlist_artwork( self.update_playlist_artwork(playlist.cover_art, order_token=order_token)
playlist.coverArt,
order_token=order_token,
)
# Update the song list model. This requires some fancy diffing to # Update the song list model. This requires some fancy diffing to
# update the list. # update the list.
self.editing_playlist_song_list = True self.editing_playlist_song_list = True
new_store = [ # This doesn't look efficient, since it's doing a ton of passses over the data,
[ # but there is some annoying memory overhead for generating the stores to diff,
util.get_cached_status_icon( # so we are short-circuiting by checking to see if any of the the IDs have
CacheManager.get_cached_status(song)), # changed.
song.title, #
song.album, # The entire algorithm ends up being O(2n), but the first loop is very tight,
song.artist, # and the expensive parts of the second loop are avoided if the IDs haven't
util.format_song_duration(song.duration), # changed.
song.id, song_ids, songs = [], []
] for song in (playlist.entry or []) for i, c in enumerate(playlist.songs):
] if i >= len(self._current_song_ids) or c.id != self._current_song_ids[i]:
force = True
song_ids.append(c.id)
songs.append(c)
util.diff_song_store(self.playlist_song_store, new_store) if force:
self._current_song_ids = song_ids
new_songs_store = [
[
status_icon,
song.title,
album.name if (album := song.album) else None,
artist.name if (artist := song.artist) else None,
util.format_song_duration(song.duration),
song.id,
]
for status_icon, song in zip(
util.get_cached_status_icons(song_ids),
[cast(API.Song, s) for s in songs],
)
]
else:
new_songs_store = [
[status_icon] + song_model[1:]
for status_icon, song_model in zip(
util.get_cached_status_icons(song_ids), self.playlist_song_store
)
]
util.diff_song_store(self.playlist_song_store, new_songs_store)
self.editing_playlist_song_list = False self.editing_playlist_song_list = False
@@ -522,14 +525,14 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.play_shuffle_buttons.show_all() self.play_shuffle_buttons.show_all()
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_cover_art_filename(*a, **k), AdapterManager.get_cover_art_filename,
before_download=lambda self: self.playlist_artwork.set_loading(True), before_download=lambda self: self.playlist_artwork.set_loading(True),
on_failure=lambda self, e: self.playlist_artwork.set_loading(False), on_failure=lambda self, e: self.playlist_artwork.set_loading(False),
) )
def update_playlist_artwork( def update_playlist_artwork(
self, self,
cover_art_filename: str, cover_art_filename: str,
state: ApplicationState, app_config: AppConfiguration,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
): ):
@@ -549,7 +552,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
def on_playlist_edit_button_click(self, _: Any): def on_playlist_edit_button_click(self, _: Any):
playlist = CacheManager.get_playlist(self.playlist_id).result() assert self.playlist_id
playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
dialog = EditPlaylistDialog(self.get_toplevel(), playlist) dialog = EditPlaylistDialog(self.get_toplevel(), playlist)
playlist_deleted = False playlist_deleted = False
@@ -557,11 +561,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Using ResponseType.NO as the delete event. # Using ResponseType.NO as the delete event.
if result in (Gtk.ResponseType.OK, Gtk.ResponseType.NO): if result in (Gtk.ResponseType.OK, Gtk.ResponseType.NO):
if result == Gtk.ResponseType.OK: if result == Gtk.ResponseType.OK:
CacheManager.update_playlist( AdapterManager.update_playlist(
self.playlist_id, self.playlist_id,
name=dialog.data['name'].get_text(), name=dialog.data["name"].get_text(),
comment=dialog.data['comment'].get_text(), comment=dialog.data["comment"].get_text(),
public=dialog.data['public'].get_active(), public=dialog.data["public"].get_active(),
) )
elif result == Gtk.ResponseType.NO: elif result == Gtk.ResponseType.NO:
# Delete the playlist. # Delete the playlist.
@@ -569,7 +573,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
transient_for=self.get_toplevel(), transient_for=self.get_toplevel(),
message_type=Gtk.MessageType.WARNING, message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.NONE, buttons=Gtk.ButtonsType.NONE,
text='Confirm deletion', text="Confirm deletion",
) )
confirm_dialog.add_buttons( confirm_dialog.add_buttons(
Gtk.STOCK_DELETE, Gtk.STOCK_DELETE,
@@ -578,12 +582,13 @@ class PlaylistDetailPanel(Gtk.Overlay):
Gtk.ResponseType.CANCEL, Gtk.ResponseType.CANCEL,
) )
confirm_dialog.format_secondary_markup( confirm_dialog.format_secondary_markup(
'Are you sure you want to delete the ' "Are you sure you want to delete the "
f'"{playlist.name}" playlist?') f'"{playlist.name}" playlist?'
)
result = confirm_dialog.run() result = confirm_dialog.run()
confirm_dialog.destroy() confirm_dialog.destroy()
if result == Gtk.ResponseType.YES: if result == Gtk.ResponseType.YES:
CacheManager.delete_playlist(self.playlist_id) AdapterManager.delete_playlist(self.playlist_id)
playlist_deleted = True playlist_deleted = True
else: else:
# In this case, we don't want to do any invalidation of # In this case, we don't want to do any invalidation of
@@ -591,14 +596,13 @@ class PlaylistDetailPanel(Gtk.Overlay):
dialog.destroy() dialog.destroy()
return return
# Invalidate the caches and force a re-fresh of the view # Force a re-fresh of the view
CacheManager.delete_cached_cover_art(self.playlist_id)
CacheManager.invalidate_playlists_cache()
self.emit( self.emit(
'refresh-window', "refresh-window",
{ {
'selected_playlist_id': "selected_playlist_id": None
None if playlist_deleted else self.playlist_id if playlist_deleted
else self.playlist_id
}, },
True, True,
) )
@@ -606,15 +610,15 @@ class PlaylistDetailPanel(Gtk.Overlay):
dialog.destroy() dialog.destroy()
def on_playlist_list_download_all_button_click(self, _: Any): def on_playlist_list_download_all_button_click(self, _: Any):
def download_state_change(*args): def download_state_change(song_id: str):
GLib.idle_add( GLib.idle_add(
lambda: self.update_playlist_view( lambda: self.update_playlist_view(
self.playlist_id, self.playlist_id, order_token=self.update_playlist_view_order_token,
order_token=self.update_playlist_view_order_token, )
)) )
song_ids = [s[-1] for s in self.playlist_song_store] song_ids = [s[-1] for s in self.playlist_song_store]
CacheManager.batch_download_songs( AdapterManager.batch_download_songs(
song_ids, song_ids,
before_download=download_state_change, before_download=download_state_change,
on_song_download_complete=download_state_change, on_song_download_complete=download_state_change,
@@ -622,43 +626,30 @@ class PlaylistDetailPanel(Gtk.Overlay):
def on_play_all_clicked(self, _: Any): def on_play_all_clicked(self, _: Any):
self.emit( self.emit(
'song-clicked', "song-clicked",
0, 0,
[m[-1] for m in self.playlist_song_store], [m[-1] for m in self.playlist_song_store],
{ {"force_shuffle_state": False, "active_playlist_id": self.playlist_id},
'force_shuffle_state': False,
'active_playlist_id': self.playlist_id,
},
) )
def on_shuffle_all_button(self, _: Any): def on_shuffle_all_button(self, _: Any):
self.emit( self.emit(
'song-clicked', "song-clicked",
randint(0, randint(0, len(self.playlist_song_store) - 1),
len(self.playlist_song_store) - 1),
[m[-1] for m in self.playlist_song_store], [m[-1] for m in self.playlist_song_store],
{ {"force_shuffle_state": True, "active_playlist_id": self.playlist_id},
'force_shuffle_state': True,
'active_playlist_id': self.playlist_id,
},
) )
def on_song_activated(self, _: Any, idx: Gtk.TreePath, col: Any): def on_song_activated(self, _: Any, idx: Gtk.TreePath, col: Any):
# The song ID is in the last column of the model. # The song ID is in the last column of the model.
self.emit( self.emit(
'song-clicked', "song-clicked",
idx.get_indices()[0], idx.get_indices()[0],
[m[-1] for m in self.playlist_song_store], [m[-1] for m in self.playlist_song_store],
{ {"active_playlist_id": self.playlist_id},
'active_playlist_id': self.playlist_id,
},
) )
def on_song_button_press( def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
self,
tree: Gtk.TreeView,
event: Gdk.EventButton,
) -> bool:
if event.button == 3: # Right click if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y) clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path: if not clicked_path:
@@ -667,12 +658,13 @@ class PlaylistDetailPanel(Gtk.Overlay):
store, paths = tree.get_selection().get_selected_rows() store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False allow_deselect = False
def on_download_state_change(): def on_download_state_change(song_id: str):
GLib.idle_add( GLib.idle_add(
lambda: self.update_playlist_view( lambda: self.update_playlist_view(
self.playlist_id, self.playlist_id,
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
)) )
)
# Use the new selection instead of the old one for calculating what # Use the new selection instead of the old one for calculating what
# to do the right click on. # to do the right click on.
@@ -683,16 +675,20 @@ class PlaylistDetailPanel(Gtk.Overlay):
song_ids = [self.playlist_song_store[p][-1] for p in paths] song_ids = [self.playlist_song_store[p][-1] for p in paths]
# Used to adjust for the header row. # Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords( bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
event.x, event.y) widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
def on_remove_songs_click(_: Any): def on_remove_songs_click(_: Any):
CacheManager.update_playlist( assert self.playlist_id
playlist_id=self.playlist_id, delete_idxs = {p.get_indices()[0] for p in paths}
song_index_to_remove=[p.get_indices()[0] for p in paths], new_song_ids = [
) model[-1]
for i, model in enumerate(self.playlist_song_store)
if i not in delete_idxs
]
AdapterManager.update_playlist(
playlist_id=self.playlist_id, song_ids=new_song_ids
).result()
self.update_playlist_view( self.update_playlist_view(
self.playlist_id, self.playlist_id,
force=True, force=True,
@@ -700,8 +696,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
remove_text = ( remove_text = (
'Remove ' + util.pluralize('song', len(song_ids)) "Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
+ ' from playlist') )
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
event.x, event.x,
@@ -711,6 +707,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
extra_menu_items=[ extra_menu_items=[
(Gtk.ModelButton(text=remove_text), on_remove_songs_click), (Gtk.ModelButton(text=remove_text), on_remove_songs_click),
], ],
on_playlist_state_change=lambda: self.emit("refresh-window", {}, True),
) )
# If the click was on a selected row, don't deselect anything. # If the click was on a selected row, don't deselect anything.
@@ -739,31 +736,16 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_artwork.set_loading(True) self.playlist_artwork.set_loading(True)
self.playlist_view_loading_box.show_all() self.playlist_view_loading_box.show_all()
def make_label( def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label:
self, return Gtk.Label(label=text, name=name, halign=Gtk.Align.START, **params,)
text: str = None,
name: str = None,
**params,
) -> Gtk.Label:
return Gtk.Label(
label=text,
name=name,
halign=Gtk.Align.START,
**params,
)
@util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k)) @util.async_callback(AdapterManager.get_playlist_details)
def _update_playlist_order( def _update_playlist_order(
self, self, playlist: API.Playlist, app_config: AppConfiguration, **kwargs,
playlist: PlaylistWithSongs,
state: ApplicationState,
**kwargs,
): ):
self.playlist_view_loading_box.show_all() self.playlist_view_loading_box.show_all()
update_playlist_future = CacheManager.update_playlist( update_playlist_future = AdapterManager.update_playlist(
playlist_id=playlist.id, playlist.id, song_ids=[s[-1] for s in self.playlist_song_store]
song_index_to_remove=list(range(playlist.songCount)),
song_id_to_add=[s[-1] for s in self.playlist_song_store],
) )
update_playlist_future.add_done_callback( update_playlist_future.add_done_callback(
@@ -772,20 +754,27 @@ class PlaylistDetailPanel(Gtk.Overlay):
playlist.id, playlist.id,
force=True, force=True,
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
))) )
)
)
def _format_stats(self, playlist: API.Playlist) -> str:
created_date_text = ""
if playlist.created:
created_date_text = f" on {playlist.created.strftime('%B %d, %Y')}"
created_text = f"Created by {playlist.owner}{created_date_text}"
def _format_stats(self, playlist: PlaylistWithSongs) -> str:
created_date = playlist.created.strftime('%B %d, %Y')
lines = [ lines = [
util.dot_join( util.dot_join(
f'Created by {playlist.owner} on {created_date}', created_text,
f"{'Not v' if not playlist.public else 'V'}isible to others", f"{'Not v' if not playlist.public else 'V'}isible to others",
), ),
util.dot_join( util.dot_join(
'{} {}'.format( "{} {}".format(
playlist.songCount, playlist.song_count,
util.pluralize("song", playlist.songCount)), util.pluralize("song", playlist.song_count or 0),
),
util.format_sequence_duration(playlist.duration), util.format_sequence_duration(playlist.duration),
), ),
] ]
return '\n'.join(lines) return "\n".join(lines)

View File

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

118
sublime/ui/state.py Normal file
View File

@@ -0,0 +1,118 @@
from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum
from typing import Any, Callable, Dict, Optional, Tuple
from sublime.adapters import AlbumSearchQuery
from sublime.adapters.api_objects import Genre, Song
class RepeatType(Enum):
NO_REPEAT = 0
REPEAT_QUEUE = 1
REPEAT_SONG = 2
@property
def icon(self) -> str:
icon_name = ["repeat-symbolic", "repeat-symbolic", "repeat-song-symbolic"][
self.value
]
return f"media-playlist-{icon_name}"
def as_mpris_loop_status(self) -> str:
return ["None", "Playlist", "Track"][self.value]
@staticmethod
def from_mpris_loop_status(loop_status: str) -> "RepeatType":
return {
"None": RepeatType.NO_REPEAT,
"Track": RepeatType.REPEAT_SONG,
"Playlist": RepeatType.REPEAT_QUEUE,
}[loop_status]
@dataclass
class UIState:
"""Represents the UI state of the application."""
@dataclass(unsafe_hash=True)
class UINotification:
markup: str
actions: Tuple[Tuple[str, Callable[[], None]], ...] = field(
default_factory=tuple
)
version: int = 1
playing: bool = False
current_song_index: int = -1
play_queue: Tuple[str, ...] = field(default_factory=tuple)
old_play_queue: Tuple[str, ...] = field(default_factory=tuple)
_volume: Dict[str, float] = field(default_factory=lambda: {"this device": 100.0})
is_muted: bool = False
repeat_type: RepeatType = RepeatType.NO_REPEAT
shuffle_on: bool = False
song_progress: timedelta = timedelta()
song_stream_cache_progress: Optional[timedelta] = timedelta()
current_device: str = "this device"
current_tab: str = "albums"
selected_album_id: Optional[str] = None
selected_artist_id: Optional[str] = None
selected_browse_element_id: Optional[str] = None
selected_playlist_id: Optional[str] = None
album_sort_direction: str = "ascending"
album_page_size: int = 30
album_page: int = 0
current_notification: Optional[UINotification] = None
def __getstate__(self):
state = self.__dict__.copy()
del state["song_stream_cache_progress"]
del state["current_notification"]
del state["playing"]
return state
def __setstate__(self, state: Dict[str, Any]):
self.__dict__.update(state)
self.song_stream_cache_progress = None
self.current_notification = None
self.playing = False
class _DefaultGenre(Genre):
def __init__(self):
self.name = "Rock"
# State for Album sort.
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
AlbumSearchQuery.Type.RANDOM, genre=_DefaultGenre(), year_range=(2010, 2020),
)
active_playlist_id: Optional[str] = None
def migrate(self):
pass
_current_song: Optional[Song] = None
@property
def current_song(self) -> Optional[Song]:
if not self.play_queue or self.current_song_index < 0:
return None
from sublime.adapters import AdapterManager
current_song_id = self.play_queue[self.current_song_index]
if not self._current_song or self._current_song.id != current_song_id:
self._current_song = AdapterManager.get_song_details(
current_song_id
).result()
return self._current_song
@property
def volume(self) -> float:
return self._volume.get(self.current_device, 100.0)
@volume.setter
def volume(self, value: float):
self._volume[self.current_device] = value

View File

@@ -1,19 +1,27 @@
import functools import functools
import re import re
from concurrent.futures import Future from datetime import timedelta
from typing import Any, Callable, cast, Iterable, List, Match, Tuple, Union from typing import (
Any,
Callable,
cast,
Iterable,
List,
Match,
Optional,
Tuple,
Union,
)
import gi
from deepdiff import DeepDiff from deepdiff import DeepDiff
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, GLib, Gtk from gi.repository import Gdk, GLib, Gtk
from sublime.cache_manager import CacheManager, SongCacheStatus from sublime.adapters import AdapterManager, Result, SongCacheStatus
from sublime.server.api_objects import Playlist from sublime.adapters.api_objects import Playlist, Song
from sublime.state_manager import ApplicationState from sublime.config import AppConfiguration
def format_song_duration(duration_secs: int) -> str: def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str:
""" """
Formats the song duration as mins:seconds with the seconds being Formats the song duration as mins:seconds with the seconds being
zero-padded if necessary. zero-padded if necessary.
@@ -22,15 +30,22 @@ def format_song_duration(duration_secs: int) -> str:
'1:20' '1:20'
>>> format_song_duration(62) >>> format_song_duration(62)
'1:02' '1:02'
>>> format_song_duration(timedelta(seconds=68.2))
'1:08'
>>> format_song_duration(None)
'-:--'
""" """
return f'{duration_secs // 60}:{duration_secs % 60:02}' if isinstance(duration_secs, timedelta):
duration_secs = round(duration_secs.total_seconds())
if duration_secs is None:
return "-:--"
duration_secs = max(duration_secs, 0)
return f"{duration_secs // 60}:{duration_secs % 60:02}"
def pluralize( def pluralize(string: str, number: int, pluralized_form: str = None,) -> str:
string: str,
number: int,
pluralized_form: str = None,
) -> str:
""" """
Pluralize the given string given the count as a number. Pluralize the given string given the count as a number.
@@ -42,69 +57,89 @@ def pluralize(
'foos' 'foos'
""" """
if number != 1: if number != 1:
return pluralized_form or f'{string}s' return pluralized_form or f"{string}s"
return string return string
def format_sequence_duration(duration_secs: int) -> str: def format_sequence_duration(duration: Optional[timedelta]) -> str:
""" """
Formats duration in English. Formats duration in English.
>>> format_sequence_duration(30) >>> format_sequence_duration(timedelta(seconds=90))
'30 seconds'
>>> format_sequence_duration(90)
'1 minute, 30 seconds' '1 minute, 30 seconds'
>>> format_sequence_duration(60 * 60 + 120) >>> format_sequence_duration(timedelta(seconds=(60 * 60 + 120)))
'1 hour, 2 minutes' '1 hour, 2 minutes'
>>> format_sequence_duration(None)
'0 seconds'
""" """
duration_secs = round(duration.total_seconds()) if duration else 0
duration_mins = (duration_secs // 60) % 60 duration_mins = (duration_secs // 60) % 60
duration_hrs = duration_secs // 60 // 60 duration_hrs = duration_secs // 60 // 60
duration_secs = duration_secs % 60 duration_secs = duration_secs % 60
format_components = [] format_components = []
if duration_hrs > 0: if duration_hrs > 0:
hrs = '{} {}'.format(duration_hrs, pluralize('hour', duration_hrs)) hrs = "{} {}".format(duration_hrs, pluralize("hour", duration_hrs))
format_components.append(hrs) format_components.append(hrs)
if duration_mins > 0: if duration_mins > 0:
mins = '{} {}'.format( mins = "{} {}".format(duration_mins, pluralize("minute", duration_mins))
duration_mins, pluralize('minute', duration_mins))
format_components.append(mins) format_components.append(mins)
# Show seconds if there are no hours. # Show seconds if there are no hours.
if duration_hrs == 0: if duration_hrs == 0:
secs = '{} {}'.format( secs = "{} {}".format(duration_secs, pluralize("second", duration_secs))
duration_secs, pluralize('second', duration_secs))
format_components.append(secs) format_components.append(secs)
return ', '.join(format_components) return ", ".join(format_components)
def esc(string: str) -> str: def esc(string: Optional[str]) -> str:
"""
>>> esc("test & <a href='ohea' target='_blank'>test</a>")
"test &amp; <a href='ohea'>test</a>"
>>> esc(None)
''
"""
if string is None: if string is None:
return None return ""
return string.replace('&', '&amp;').replace(" target='_blank'", '') return string.replace("&", "&amp;").replace(" target='_blank'", "")
def dot_join(*items: Any) -> str: def dot_join(*items: Any) -> str:
""" """
Joins the given strings with a dot character. Filters out None values. Joins the given strings with a dot character. Filters out ``None`` values.
>>> dot_join(None, "foo", "bar", None, "baz")
'foo • bar • baz'
""" """
return ''.join(map(str, filter(lambda x: x is not None, items))) return "".join(map(str, filter(lambda x: x is not None, items)))
def get_cached_status_icon(cache_status: SongCacheStatus) -> str: def get_cached_status_icons(song_ids: List[str]) -> List[str]:
cache_icon = { cache_icon = {
SongCacheStatus.NOT_CACHED: '', SongCacheStatus.CACHED: "folder-download-symbolic",
SongCacheStatus.CACHED: 'folder-download-symbolic', SongCacheStatus.PERMANENTLY_CACHED: "view-pin-symbolic",
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic', SongCacheStatus.DOWNLOADING: "emblem-synchronizing-symbolic",
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
} }
return cache_icon[cache_status] return [
cache_icon.get(cache_status, "")
for cache_status in AdapterManager.get_cached_statuses(song_ids)
]
def _parse_diff_location(location: str) -> Tuple: def _parse_diff_location(location: str) -> Tuple:
match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location) """
Parses a diff location as returned by deepdiff.
>>> _parse_diff_location("root[22]")
('22',)
>>> _parse_diff_location("root[22][4]")
('22', '4')
>>> _parse_diff_location("root[22].foo")
('22', 'foo')
"""
match = re.match(r"root\[(\d*)\](?:\[(\d*)\]|\.(.*))?", location)
return tuple(g for g in cast(Match, match).groups() if g is not None) return tuple(g for g in cast(Match, match).groups() if g is not None)
@@ -117,18 +152,18 @@ def diff_song_store(store_to_edit: Any, new_store: Iterable[Any]):
# Diff the lists to determine what needs to be changed. # Diff the lists to determine what needs to be changed.
diff = DeepDiff(old_store, new_store) diff = DeepDiff(old_store, new_store)
changed = diff.get('values_changed', {}) changed = diff.get("values_changed", {})
added = diff.get('iterable_item_added', {}) added = diff.get("iterable_item_added", {})
removed = diff.get('iterable_item_removed', {}) removed = diff.get("iterable_item_removed", {})
for edit_location, diff in changed.items(): for edit_location, diff in changed.items():
idx, field = _parse_diff_location(edit_location) idx, field = _parse_diff_location(edit_location)
store_to_edit[int(idx)][int(field)] = diff['new_value'] store_to_edit[int(idx)][int(field)] = diff["new_value"]
for add_location, value in added.items(): for _, value in added.items():
store_to_edit.append(value) store_to_edit.append(value)
for remove_location, value in reversed(list(removed.items())): for remove_location, _ in reversed(list(removed.items())):
remove_at = int(_parse_diff_location(remove_location)[0]) remove_at = int(_parse_diff_location(remove_location)[0])
del store_to_edit[remove_at] del store_to_edit[remove_at]
@@ -144,40 +179,37 @@ def diff_model_store(store_to_edit: Any, new_store: Iterable[Any]):
if diff == {}: if diff == {}:
return return
store_to_edit.remove_all() store_to_edit.splice(0, len(store_to_edit), new_store)
for model in new_store:
store_to_edit.append(model)
def show_song_popover( def show_song_popover(
song_ids: List[int], song_ids: List[str],
x: int, x: int,
y: int, y: int,
relative_to: Any, relative_to: Any,
position: Gtk.PositionType = Gtk.PositionType.BOTTOM, position: Gtk.PositionType = Gtk.PositionType.BOTTOM,
on_download_state_change: Callable[[], None] = lambda: None, on_download_state_change: Callable[[str], None] = lambda _: None,
on_playlist_state_change: Callable[[], None] = lambda: None,
show_remove_from_playlist_button: bool = False, show_remove_from_playlist_button: bool = False,
extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = [], extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = None,
): ):
def on_download_songs_click(_: Any): def on_download_songs_click(_: Any):
CacheManager.batch_download_songs( AdapterManager.batch_download_songs(
song_ids, song_ids,
before_download=on_download_state_change, before_download=on_download_state_change,
on_song_download_complete=on_download_state_change, on_song_download_complete=on_download_state_change,
) )
def on_remove_downloads_click(_: Any): def on_remove_downloads_click(_: Any):
CacheManager.batch_delete_cached_songs( AdapterManager.batch_delete_cached_songs(
song_ids, song_ids, on_song_delete=on_download_state_change,
on_song_delete=on_download_state_change,
) )
def on_add_to_playlist_click(_: Any, playlist: Playlist): def on_add_to_playlist_click(_: Any, playlist: Playlist):
CacheManager.executor.submit( update_playlist_result = AdapterManager.update_playlist(
CacheManager.update_playlist, playlist_id=playlist.id, append_song_ids=song_ids
playlist_id=playlist.id,
song_id_to_add=song_ids,
) )
update_playlist_result.add_done_callback(lambda _: on_playlist_state_change)
popover = Gtk.PopoverMenu() popover = Gtk.PopoverMenu()
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
@@ -185,90 +217,94 @@ def show_song_popover(
# Add all of the menu items to the popover. # Add all of the menu items to the popover.
song_count = len(song_ids) song_count = len(song_ids)
# Determine if we should enable the download button.
download_sensitive, remove_download_sensitive = False, False
albums, artists, parents = set(), set(), set()
for song_id in song_ids:
details = CacheManager.get_song_details(song_id).result()
status = CacheManager.get_cached_status(details)
albums.add(details.albumId)
artists.add(details.artistId)
parents.add(details.parent)
if download_sensitive or status == SongCacheStatus.NOT_CACHED:
download_sensitive = True
if (remove_download_sensitive
or status in (SongCacheStatus.CACHED,
SongCacheStatus.PERMANENTLY_CACHED)):
remove_download_sensitive = True
go_to_album_button = Gtk.ModelButton( go_to_album_button = Gtk.ModelButton(
text='Go to album', action_name='app.go-to-album') text="Go to album", action_name="app.go-to-album"
if len(albums) == 1 and list(albums)[0] is not None: )
album_value = GLib.Variant('s', list(albums)[0]) go_to_artist_button = Gtk.ModelButton(
go_to_album_button.set_action_target_value(album_value) text="Go to artist", action_name="app.go-to-artist"
)
go_to_artist_button = Gtk.ModelButton( browse_to_song = Gtk.ModelButton(
text='Go to artist', action_name='app.go-to-artist') text=f"Browse to {pluralize('song', song_count)}", action_name="app.browse-to",
if len(artists) == 1 and list(artists)[0] is not None: )
artist_value = GLib.Variant('s', list(artists)[0]) download_song_button = Gtk.ModelButton(
go_to_artist_button.set_action_target_value(artist_value) text=f"Download {pluralize('song', song_count)}", sensitive=False
)
browse_to_song = Gtk.ModelButton( remove_download_button = Gtk.ModelButton(
text=f"Browse to {pluralize('song', song_count)}", text=f"Remove {pluralize('download', song_count)}", sensitive=False
action_name='app.browse-to', )
# Retrieve songs and set the buttons as sensitive later.
def on_get_song_details_done(songs: List[Song]):
song_cache_statuses = AdapterManager.get_cached_statuses([s.id for s in songs])
if any(status == SongCacheStatus.NOT_CACHED for status in song_cache_statuses):
download_song_button.set_sensitive(True)
if any(
status in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
for status in song_cache_statuses
):
download_song_button.set_sensitive(True)
albums, artists, parents = set(), set(), set()
for song in songs:
albums.add(album.id if (album := song.album) else None)
artists.add(artist.id if (artist := song.artist) else None)
parents.add(parent_id if (parent_id := song.parent_id) else None)
if len(albums) == 1 and list(albums)[0] is not None:
album_value = GLib.Variant("s", list(albums)[0])
go_to_album_button.set_action_target_value(album_value)
if len(artists) == 1 and list(artists)[0] is not None:
artist_value = GLib.Variant("s", list(artists)[0])
go_to_artist_button.set_action_target_value(artist_value)
if len(parents) == 1 and list(parents)[0] is not None:
parent_value = GLib.Variant("s", list(parents)[0])
browse_to_song.set_action_target_value(parent_value)
def batch_get_song_details() -> List[Song]:
return [
AdapterManager.get_song_details(song_id).result() for song_id in song_ids
]
get_song_details_result: Result[List[Song]] = Result(batch_get_song_details)
get_song_details_result.add_done_callback(
lambda f: GLib.idle_add(on_get_song_details_done, f.result())
) )
if len(parents) == 1 and list(parents)[0] is not None:
parent_value = GLib.Variant('s', list(parents)[0])
browse_to_song.set_action_target_value(parent_value)
menu_items = [ menu_items = [
Gtk.ModelButton( Gtk.ModelButton(
text='Play next', text="Play next",
action_name='app.play-next', action_name="app.play-next",
action_target=GLib.Variant('as', song_ids), action_target=GLib.Variant("as", song_ids),
), ),
Gtk.ModelButton( Gtk.ModelButton(
text='Add to queue', text="Add to queue",
action_name='app.add-to-queue', action_name="app.add-to-queue",
action_target=GLib.Variant('as', song_ids), action_target=GLib.Variant("as", song_ids),
), ),
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
go_to_album_button, go_to_album_button,
go_to_artist_button, go_to_artist_button,
browse_to_song, browse_to_song,
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
( (download_song_button, on_download_songs_click),
Gtk.ModelButton( (remove_download_button, on_remove_downloads_click),
text=f"Download {pluralize('song', song_count)}",
sensitive=download_sensitive,
),
on_download_songs_click,
),
(
Gtk.ModelButton(
text=f"Remove {pluralize('download', song_count)}",
sensitive=remove_download_sensitive,
),
on_remove_downloads_click,
),
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
Gtk.ModelButton( Gtk.ModelButton(
text=f"Add {pluralize('song', song_count)} to playlist", text=f"Add {pluralize('song', song_count)} to playlist",
menu_name='add-to-playlist', menu_name="add-to-playlist",
name="menu-item-add-to-playlist",
), ),
*extra_menu_items, *(extra_menu_items or []),
] ]
for item in menu_items: for item in menu_items:
if type(item) == tuple: if type(item) == tuple:
el, fn = item el, fn = item
el.connect('clicked', fn) el.connect("clicked", fn)
el.get_style_context().add_class('menu-button') el.get_style_context().add_class("menu-button")
vbox.pack_start(item[0], False, True, 0) vbox.pack_start(item[0], False, True, 0)
else: else:
item.get_style_context().add_class('menu-button') item.get_style_context().add_class("menu-button")
vbox.pack_start(item, False, True, 0) vbox.pack_start(item, False, True, 0)
popover.add(vbox) popover.add(vbox)
@@ -277,22 +313,29 @@ def show_song_popover(
playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Back button # Back button
playlists_vbox.add( playlists_vbox.add(Gtk.ModelButton(inverted=True, centered=True, menu_name="main"))
Gtk.ModelButton(
inverted=True,
centered=True,
menu_name='main',
))
# The playlist buttons # Loading indicator
for playlist in CacheManager.get_playlists().result(): loading_indicator = Gtk.Spinner(name="menu-item-spinner")
button = Gtk.ModelButton(text=playlist.name) loading_indicator.start()
button.get_style_context().add_class('menu-button') playlists_vbox.add(loading_indicator)
button.connect('clicked', on_add_to_playlist_click, playlist)
playlists_vbox.pack_start(button, False, True, 0) # Create a future to make the actual playlist buttons
def on_get_playlists_done(f: Result[List[Playlist]]):
playlists_vbox.remove(loading_indicator)
for playlist in f.result():
button = Gtk.ModelButton(text=playlist.name)
button.get_style_context().add_class("menu-button")
button.connect("clicked", on_add_to_playlist_click, playlist)
button.show()
playlists_vbox.pack_start(button, False, True, 0)
playlists_result = AdapterManager.get_playlists()
playlists_result.add_done_callback(on_get_playlists_done)
popover.add(playlists_vbox) popover.add(playlists_vbox)
popover.child_set_property(playlists_vbox, 'submenu', 'add-to-playlist') popover.child_set_property(playlists_vbox, "submenu", "add-to-playlist")
# Positioning of the popover. # Positioning of the popover.
rect = Gdk.Rectangle() rect = Gdk.Rectangle()
@@ -306,38 +349,35 @@ def show_song_popover(
def async_callback( def async_callback(
future_fn: Callable[..., Future], future_fn: Callable[..., Result],
before_download: Callable[[Any], None] = None, before_download: Callable[[Any], None] = None,
on_failure: Callable[[Any, Exception], None] = None, on_failure: Callable[[Any, Exception], None] = None,
) -> Callable[[Callable], Callable]: ) -> Callable[[Callable], Callable]:
""" """
Defines the ``async_callback`` decorator. Defines the ``async_callback`` decorator.
When a function is annotated with this decorator, the function becomes the When a function is annotated with this decorator, the function becomes the done
done callback for the given future-generating lambda function. The callback for the given result-generating lambda function. The annotated function
annotated function will be called with the result of the future generated will be called with the result of the Result generated by said lambda function.
by said lambda function.
:param future_fn: a function which generates a :param future_fn: a function which generates an :class:`AdapterManager.Result`.
:class:`concurrent.futures.Future` or :class:`CacheManager.Result`.
""" """
def decorator(callback_fn: Callable) -> Callable: def decorator(callback_fn: Callable) -> Callable:
@functools.wraps(callback_fn) @functools.wraps(callback_fn)
def wrapper( def wrapper(
self: Any, self: Any,
*args, *args,
state: ApplicationState = None, app_config: AppConfiguration = None,
force: bool = False, force: bool = False,
order_token: int = None, order_token: int = None,
**kwargs, **kwargs,
): ):
if before_download: def on_before_download():
on_before_download = ( if before_download:
lambda: GLib.idle_add(before_download, self)) GLib.idle_add(before_download, self)
else:
on_before_download = (lambda: None)
def future_callback(f: Union[Future, CacheManager.Result]): def future_callback(is_immediate: bool, f: Result):
try: try:
result = f.result() result = f.result()
except Exception as e: except Exception as e:
@@ -345,22 +385,31 @@ def async_callback(
GLib.idle_add(on_failure, self, e) GLib.idle_add(on_failure, self, e)
return return
GLib.idle_add( fn = functools.partial(
lambda: callback_fn( callback_fn,
self, self,
result, result,
state=state, app_config=app_config,
force=force, force=force,
order_token=order_token, order_token=order_token,
)) )
future: Union[Future, CacheManager.Result] = future_fn( if is_immediate:
*args, # The data is available now, no need to wait for the future to
before_download=on_before_download, # finish, and no need to incur the overhead of adding to the GLib
force=force, # event queue.
**kwargs, fn()
else:
# We don'h have the data, and we have to idle add so that we don't
# seg fault GTK.
GLib.idle_add(fn)
result: Result = future_fn(
*args, before_download=on_before_download, force=force, **kwargs,
)
result.add_done_callback(
functools.partial(future_callback, result.data_is_available)
) )
future.add_done_callback(future_callback)
return wrapper return wrapper

View File

@@ -0,0 +1,135 @@
from pathlib import Path
from time import sleep
import pytest
from sublime.adapters import AdapterManager, Result, SearchResult
from sublime.config import AppConfiguration, ServerConfiguration
@pytest.fixture
def adapter_manager(tmp_path: Path):
config = AppConfiguration(
servers=[
ServerConfiguration(
name="foo", server_address="bar", username="baz", password="ohea",
)
],
current_server_index=0,
cache_location=tmp_path.as_posix(),
)
AdapterManager.reset(config)
yield
AdapterManager.shutdown()
def test_result_immediate():
result = Result(42)
assert result.data_is_available
assert result.result() == 42
def test_result_immediate_callback():
callback_called = True
def check_done_callback(f: Result):
nonlocal callback_called
assert f.result() == 42
callback_called = True
result = Result(42)
result.add_done_callback(check_done_callback)
assert callback_called
def test_result_future():
def resolve_later() -> int:
sleep(0.1)
return 42
result = Result(resolve_later)
assert not result.data_is_available
assert result.result() == 42
assert result.data_is_available
def test_result_future_callback():
def resolve_later() -> int:
sleep(0.1)
return 42
check_done = False
def check_done_callback(f: Result):
nonlocal check_done
assert result.data_is_available
assert f.result() == 42
check_done = True
result = Result(resolve_later)
result.add_done_callback(check_done_callback)
# Should take much less than 1 seconds to complete. If the assertion fails, then the
# check_done_callback failed.
t = 0
while not check_done:
assert t < 1
t += 0.1
sleep(0.1)
def test_default_value():
def resolve_fail() -> int:
sleep(0.1)
raise Exception()
result = Result(resolve_fail, default_value=42)
assert not result.data_is_available
assert result.result() == 42
assert result.data_is_available
def test_cancel():
def resolve_later() -> int:
sleep(0.1)
return 42
cancel_called = False
def on_cancel():
nonlocal cancel_called
cancel_called = True
result = Result(resolve_later, on_cancel=on_cancel)
result.cancel()
assert cancel_called
assert not result.data_is_available
with pytest.raises(Exception):
result.result()
def test_get_song_details(adapter_manager: AdapterManager):
# song = AdapterManager.get_song_details("1")
# print(song)
# assert 0
# TODO
pass
def test_search(adapter_manager: AdapterManager):
# TODO
return
results = []
# TODO 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
while len(results) < 1:
sleep(0.1)
assert len(results) == 1

View File

@@ -0,0 +1,994 @@
import json
import shutil
from dataclasses import asdict
from datetime import timedelta
from pathlib import Path
from typing import Any, cast, Generator, Iterable, Tuple
import pytest
from peewee import SelectQuery
from sublime.adapters import (
AlbumSearchQuery,
api_objects as SublimeAPI,
CacheMissError,
SongCacheStatus,
)
from sublime.adapters.filesystem import FilesystemAdapter
from sublime.adapters.subsonic import api_objects as SubsonicAPI
MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
MOCK_ALBUM_ART = MOCK_DATA_FILES.joinpath("album-art.png")
MOCK_ALBUM_ART2 = MOCK_DATA_FILES.joinpath("album-art2.png")
MOCK_ALBUM_ART3 = MOCK_DATA_FILES.joinpath("album-art3.png")
MOCK_SONG_FILE = MOCK_DATA_FILES.joinpath("test-song.mp3")
MOCK_SONG_FILE2 = MOCK_DATA_FILES.joinpath("test-song2.mp3")
MOCK_ALBUM_ART_HASH = "5d7bee4f3fe25b18cd2a66f1c9767e381bc64328"
MOCK_ALBUM_ART2_HASH = "031a8a1ca01f64f851a22d5478e693825a00fb23"
MOCK_ALBUM_ART3_HASH = "46a8af0f8fe370e59202a545803e8bbb3a4a41ee"
MOCK_SONG_FILE_HASH = "fe12d0712dbfd6ff7f75ef3783856a7122a78b0a"
MOCK_SONG_FILE2_HASH = "c32597c724e2e484dbf5856930b2e5bb80de13b7"
MOCK_SUBSONIC_SONGS = [
SubsonicAPI.Song(
"2",
title="Song 2",
parent_id="d1",
_album="foo",
album_id="a1",
_artist="cool",
artist_id="art1",
duration=timedelta(seconds=20.8),
path="foo/song2.mp3",
cover_art="s2",
_genre="Bar",
),
SubsonicAPI.Song(
"1",
title="Song 1",
parent_id="d1",
_album="foo",
album_id="a1",
_artist="foo",
artist_id="art2",
duration=timedelta(seconds=10.2),
path="foo/song1.mp3",
cover_art="s1",
_genre="Foo",
),
SubsonicAPI.Song(
"1",
title="Song 1",
parent_id="d1",
_album="foo",
album_id="a1",
_artist="foo",
artist_id="art2",
duration=timedelta(seconds=10.2),
path="foo/song1.mp3",
cover_art="s1",
_genre="Foo",
),
]
KEYS = FilesystemAdapter.CachedDataKey
@pytest.fixture
def adapter(tmp_path: Path):
adapter = FilesystemAdapter({}, tmp_path)
yield adapter
adapter.shutdown()
@pytest.fixture
def cache_adapter(tmp_path: Path):
adapter = FilesystemAdapter({}, tmp_path, is_cache=True)
yield adapter
adapter.shutdown()
def mock_data_files(
request_name: str, mode: str = "r",
) -> Generator[Tuple[Path, Any], None, None]:
"""
Yields all of the files in the mock_data directory that start with ``request_name``.
"""
for file in MOCK_DATA_FILES.iterdir():
if file.name.split("-")[0] in request_name:
with open(file, mode) as f:
yield file, f.read()
def verify_songs(
actual_songs: Iterable[SublimeAPI.Song], expected_songs: Iterable[SubsonicAPI.Song]
):
actual_songs, expected_songs = (list(actual_songs), list(expected_songs))
assert len(actual_songs) == len(expected_songs)
for actual, song in zip(actual_songs, expected_songs):
for k, v in asdict(song).items():
if k in ("_genre", "_album", "_artist", "album_id", "artist_id"):
continue
print(k, "->", v) # noqa: T001
actual_value = getattr(actual, k, None)
if k == "album":
assert ("a1", "foo") == (actual_value.id, actual_value.name)
elif k == "genre":
assert v["name"] == actual_value.name
elif k == "artist":
assert (v["id"], v["name"]) == (actual_value.id, actual_value.name)
else:
assert actual_value == v
def test_caching_get_playlists(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_playlists()
# Ingest an empty list (for example, no playlists added yet to server).
cache_adapter.ingest_new_data(KEYS.PLAYLISTS, None, [])
# After the first cache miss of get_playlists, even if an empty list is
# returned, the next one should not be a cache miss.
cache_adapter.get_playlists()
# Ingest two playlists.
cache_adapter.ingest_new_data(
KEYS.PLAYLISTS,
None,
[
SubsonicAPI.Playlist("1", "test1", comment="comment"),
SubsonicAPI.Playlist("2", "test2"),
],
)
playlists = cache_adapter.get_playlists()
assert len(playlists) == 2
assert (playlists[0].id, playlists[0].name, playlists[0].comment) == (
"1",
"test1",
"comment",
)
assert (playlists[1].id, playlists[1].name) == ("2", "test2")
# Ingest a new playlist list with one of them deleted.
cache_adapter.ingest_new_data(
KEYS.PLAYLISTS,
None,
[
SubsonicAPI.Playlist("1", "test1", comment="comment"),
SubsonicAPI.Playlist("3", "test3"),
],
)
# Now, Playlist 2 should be gone.
playlists = cache_adapter.get_playlists()
assert len(playlists) == 2
assert (playlists[0].id, playlists[0].name, playlists[0].comment) == (
"1",
"test1",
"comment",
)
assert (playlists[1].id, playlists[1].name) == ("3", "test3")
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
# a ground truth adapter)
# cache_adapter.create_playlist()
adapter.get_playlists()
# TODO: verify playlist
def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_playlist_details("1")
# Simulate the playlist being retrieved from Subsonic.
cache_adapter.ingest_new_data(
KEYS.PLAYLIST_DETAILS,
"1",
SubsonicAPI.Playlist("1", "test1", songs=MOCK_SUBSONIC_SONGS[:2]),
)
playlist = cache_adapter.get_playlist_details("1")
assert playlist.id == "1"
assert playlist.name == "test1"
assert playlist.song_count == 2
assert playlist.duration == timedelta(seconds=31)
verify_songs(playlist.songs, MOCK_SUBSONIC_SONGS[:2])
# "Force refresh" the playlist and add a new song (duplicate).
cache_adapter.ingest_new_data(
KEYS.PLAYLIST_DETAILS,
"1",
SubsonicAPI.Playlist("1", "foo", songs=MOCK_SUBSONIC_SONGS),
)
playlist = cache_adapter.get_playlist_details("1")
assert playlist.id == "1"
assert playlist.name == "foo"
assert playlist.song_count == 3
assert playlist.duration == timedelta(seconds=41.2)
verify_songs(playlist.songs, MOCK_SUBSONIC_SONGS)
with pytest.raises(CacheMissError):
cache_adapter.get_playlist_details("2")
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
# a ground truth adapter)
# cache_adapter.create_playlist()
# adapter.get_playlist_details('1')
# TODO: verify playlist details
def test_caching_get_playlist_then_details(cache_adapter: FilesystemAdapter):
# Ingest a list of playlists (like the sidebar, without songs)
cache_adapter.ingest_new_data(
KEYS.PLAYLISTS,
None,
[SubsonicAPI.Playlist("1", "test1"), SubsonicAPI.Playlist("2", "test2")],
)
# Trying to get playlist details should generate a cache miss, but should
# include the data that we know about.
try:
cache_adapter.get_playlist_details("1")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data.id == "1"
assert e.partial_data.name == "test1"
# Simulate getting playlist details for id=1, then id=2
cache_adapter.ingest_new_data(
KEYS.PLAYLIST_DETAILS, "1", SubsonicAPI.Playlist("1", "test1"),
)
cache_adapter.ingest_new_data(
KEYS.PLAYLIST_DETAILS,
"2",
SubsonicAPI.Playlist("2", "test2", songs=MOCK_SUBSONIC_SONGS),
)
# Going back and getting playlist details for the first one should not
# cache miss.
playlist = cache_adapter.get_playlist_details("1")
assert playlist.id == "1"
assert playlist.name == "test1"
def test_cache_cover_art(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_cover_art_uri("pl_test1", "file", size=300)
# After ingesting the data, reading from the cache should give the exact same file.
cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "pl_test1", MOCK_ALBUM_ART)
with open(
cache_adapter.get_cover_art_uri("pl_test1", "file", size=300), "wb+"
) as cached:
with open(MOCK_ALBUM_ART, "wb+") as expected:
assert cached.read() == expected.read()
def test_invalidate_playlist(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(
KEYS.PLAYLISTS,
None,
[SubsonicAPI.Playlist("1", "test1"), SubsonicAPI.Playlist("2", "test2")],
)
cache_adapter.ingest_new_data(
KEYS.COVER_ART_FILE, "pl_test1", MOCK_ALBUM_ART,
)
cache_adapter.ingest_new_data(
KEYS.PLAYLIST_DETAILS,
"2",
SubsonicAPI.Playlist("2", "test2", cover_art="pl_2", songs=[]),
)
cache_adapter.ingest_new_data(
KEYS.COVER_ART_FILE, "pl_2", MOCK_ALBUM_ART2,
)
stale_uri_1 = cache_adapter.get_cover_art_uri("pl_test1", "file", size=300)
stale_uri_2 = cache_adapter.get_cover_art_uri("pl_2", "file", size=300)
cache_adapter.invalidate_data(KEYS.PLAYLISTS, None)
cache_adapter.invalidate_data(KEYS.PLAYLIST_DETAILS, "2")
cache_adapter.invalidate_data(KEYS.COVER_ART_FILE, "pl_test1")
# After invalidating the data, it should cache miss, but still have the old, stale,
# data.
try:
cache_adapter.get_playlists()
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert len(e.partial_data) == 2
try:
cache_adapter.get_cover_art_uri("pl_test1", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_uri_1
try:
cache_adapter.get_playlist_details("2")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
# Even though the pl_2 cover art file wasn't explicitly invalidated, it should have
# been invalidated with the playlist details invalidation.
try:
cache_adapter.get_cover_art_uri("pl_2", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_uri_2
def test_invalidate_song_file(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(KEYS.SONG, "2", MOCK_SUBSONIC_SONGS[0])
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
cache_adapter.ingest_new_data(
KEYS.COVER_ART_FILE, "s1", MOCK_ALBUM_ART,
)
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE))
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "2", (None, MOCK_SONG_FILE2))
cache_adapter.invalidate_data(KEYS.SONG_FILE, "1")
cache_adapter.invalidate_data(KEYS.COVER_ART_FILE, "s1")
with pytest.raises(CacheMissError):
cache_adapter.get_song_uri("1", "file")
with pytest.raises(CacheMissError):
cache_adapter.get_cover_art_uri("s1", "file", size=300)
# Make sure it didn't delete the other song.
assert cache_adapter.get_song_uri("2", "file").endswith("song2.mp3")
def test_malformed_song_path(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
cache_adapter.ingest_new_data(KEYS.SONG, "2", MOCK_SUBSONIC_SONGS[0])
cache_adapter.ingest_new_data(
KEYS.SONG_FILE, "1", ("/malformed/path", MOCK_SONG_FILE)
)
cache_adapter.ingest_new_data(
KEYS.SONG_FILE, "2", ("fine/path/song2.mp3", MOCK_SONG_FILE2)
)
song_uri = cache_adapter.get_song_uri("1", "file")
assert song_uri.endswith(f"/music/{MOCK_SONG_FILE_HASH}")
song_uri2 = cache_adapter.get_song_uri("2", "file")
assert song_uri2.endswith("fine/path/song2.mp3")
def test_get_cached_statuses(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.NOT_CACHED}
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE))
assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.CACHED}
cache_adapter.ingest_new_data(KEYS.SONG_FILE_PERMANENT, "1", None)
assert cache_adapter.get_cached_statuses(["1"]) == {
"1": SongCacheStatus.PERMANENTLY_CACHED
}
cache_adapter.invalidate_data(KEYS.SONG_FILE, "1")
assert cache_adapter.get_cached_statuses(["1"]) == {
"1": SongCacheStatus.CACHED_STALE
}
cache_adapter.delete_data(KEYS.SONG_FILE, "1")
assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.NOT_CACHED}
def test_delete_playlists(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(
KEYS.PLAYLIST_DETAILS,
"1",
SubsonicAPI.Playlist("1", "test1", cover_art="pl_1", songs=[]),
)
cache_adapter.ingest_new_data(
KEYS.PLAYLIST_DETAILS,
"2",
SubsonicAPI.Playlist("2", "test1", cover_art="pl_2", songs=[]),
)
cache_adapter.ingest_new_data(
KEYS.COVER_ART_FILE, "pl_1", MOCK_ALBUM_ART,
)
# Deleting a playlist should get rid of it entirely.
cache_adapter.delete_data(KEYS.PLAYLIST_DETAILS, "2")
try:
cache_adapter.get_playlist_details("2")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data is None
# Deleting a playlist with associated cover art should get rid the cover art too.
cache_adapter.delete_data(KEYS.PLAYLIST_DETAILS, "1")
try:
cache_adapter.get_cover_art_uri("pl_1", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data is None
# Even if the cover art failed to be deleted, it should cache miss.
shutil.copy(
MOCK_ALBUM_ART, str(cache_adapter.cover_art_dir.joinpath(MOCK_ALBUM_ART_HASH)),
)
try:
cache_adapter.get_cover_art_uri("pl_1", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data is None
def test_delete_song_data(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE))
cache_adapter.ingest_new_data(
KEYS.COVER_ART_FILE, "s1", MOCK_ALBUM_ART,
)
music_file_path = cache_adapter.get_song_uri("1", "file")
cover_art_path = cache_adapter.get_cover_art_uri("s1", "file", size=300)
cache_adapter.delete_data(KEYS.SONG_FILE, "1")
cache_adapter.delete_data(KEYS.COVER_ART_FILE, "s1")
assert not Path(music_file_path).exists()
assert not Path(cover_art_path).exists()
try:
cache_adapter.get_song_uri("1", "file")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data is None
try:
cache_adapter.get_cover_art_uri("s1", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data is None
def test_caching_get_genres(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_genres()
cache_adapter.ingest_new_data(KEYS.SONG, "2", MOCK_SUBSONIC_SONGS[0])
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
# Getting genres now should look at what's on the songs. This sould cache miss, but
# still give some data.
try:
cache_adapter.get_genres()
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert [g.name for g in cast(Iterable, e.partial_data)] == ["Bar", "Foo"]
# After we actually ingest the actual list, it should be returned instead.
cache_adapter.ingest_new_data(
KEYS.GENRES,
None,
[
SubsonicAPI.Genre("Bar", 10, 20),
SubsonicAPI.Genre("Baz", 10, 20),
SubsonicAPI.Genre("Foo", 10, 20),
],
)
assert {g.name for g in cache_adapter.get_genres()} == {"Bar", "Baz", "Foo"}
def test_caching_get_song_details(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_song_details("1")
# Simulate the song details being retrieved from Subsonic.
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
song = cache_adapter.get_song_details("1")
assert song.id == "1"
assert song.title == "Song 1"
assert song.album
assert (song.album.id, song.album.name) == ("a1", "foo")
assert song.artist and song.artist.name == "foo"
assert song.parent_id == "d1"
assert song.duration == timedelta(seconds=10.2)
assert song.path == "foo/song1.mp3"
assert song.genre and song.genre.name == "Foo"
# "Force refresh" the song details
cache_adapter.ingest_new_data(
KEYS.SONG,
"1",
SubsonicAPI.Song(
"1",
title="Song 1",
parent_id="bar",
_album="bar",
album_id="a2",
_artist="bar",
artist_id="art2",
duration=timedelta(seconds=10.2),
path="bar/song1.mp3",
_genre="Bar",
),
)
song = cache_adapter.get_song_details("1")
assert song.id == "1"
assert song.title == "Song 1"
assert song.album and song.artist
assert (song.album.id, song.album.name) == ("a2", "bar")
assert (song.artist.id, song.artist.name) == ("art2", "bar")
assert song.parent_id == "bar"
assert song.duration == timedelta(seconds=10.2)
assert song.path == "bar/song1.mp3"
assert song.genre and song.genre.name == "Bar"
with pytest.raises(CacheMissError):
cache_adapter.get_playlist_details("2")
def test_caching_get_song_details_missing_data(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_song_details("1")
# Ingest a song without an album ID and artist ID, but with album and artist name.
cache_adapter.ingest_new_data(
KEYS.SONG,
"1",
SubsonicAPI.Song(
"1",
title="Song 1",
parent_id="bar",
_album="bar",
_artist="foo",
duration=timedelta(seconds=10.2),
path="foo/bar/song1.mp3",
_genre="Bar",
),
)
song = cache_adapter.get_song_details("1")
assert song.id == "1"
assert song.title == "Song 1"
assert song.album
assert (song.album.id, song.album.name) == (
"invalid:62cdb7020ff920e5aa642c3d4066950dd1f01f4d",
"bar",
)
assert (song.artist.id, song.artist.name) == (
"invalid:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33",
"foo",
)
assert song.parent_id == "bar"
assert song.duration == timedelta(seconds=10.2)
assert song.path == "foo/bar/song1.mp3"
assert song.genre and song.genre.name == "Bar"
# Because the album and artist are invalid (doesn't have an album/artist ID), it
# shouldn't show up in any results.
try:
list(
cache_adapter.get_albums(
AlbumSearchQuery(AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME)
)
)
except CacheMissError as e:
assert e.partial_data is not None
assert len(e.partial_data) == 0
albums = list(cache_adapter.get_all_albums())
assert len(albums) == 0
with pytest.raises(CacheMissError):
cache_adapter.get_album("invalid:62cdb7020ff920e5aa642c3d4066950dd1f01f4d")
try:
list(cache_adapter.get_artists())
except CacheMissError as e:
assert e.partial_data is not None
assert len(e.partial_data) == 0
with pytest.raises(CacheMissError):
cache_adapter.get_artist("invalid:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33")
def test_caching_less_info(cache_adapter: FilesystemAdapter):
cache_adapter.ingest_new_data(
KEYS.SONG,
"1",
SubsonicAPI.Song(
"1",
title="Song 1",
parent_id="bar",
_album="bar",
album_id="a2",
_artist="bar",
artist_id="art2",
duration=timedelta(seconds=10.2),
path="bar/song1.mp3",
_genre="Bar",
),
)
cache_adapter.ingest_new_data(
KEYS.SONG,
"1",
SubsonicAPI.Song(
"1",
title="Song 1",
parent_id="bar",
duration=timedelta(seconds=10.2),
path="bar/song1.mp3",
),
)
song = cache_adapter.get_song_details("1")
assert song.album and song.album.name == "bar"
assert song.artist and song.artist.name == "bar"
assert song.genre and song.genre.name == "Bar"
def test_caching_get_artists(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_artists()
# Ingest artists.
cache_adapter.ingest_new_data(
KEYS.ARTISTS,
None,
[
SubsonicAPI.ArtistAndArtistInfo(
id="1", name="test1", album_count=3, albums=[]
),
SubsonicAPI.ArtistAndArtistInfo(id="2", name="test2", album_count=4),
],
)
artists = cache_adapter.get_artists()
assert len(artists) == 2
assert (artists[0].id, artists[0].name, artists[0].album_count) == ("1", "test1", 3)
assert (artists[1].id, artists[1].name, artists[1].album_count) == ("2", "test2", 4)
# Ingest a new artists list with one of them deleted.
cache_adapter.ingest_new_data(
KEYS.ARTISTS,
None,
[
SubsonicAPI.ArtistAndArtistInfo(id="1", name="test1", album_count=3),
SubsonicAPI.ArtistAndArtistInfo(id="3", name="test3", album_count=8),
],
)
# Now, artist 2 should be gone.
artists = cache_adapter.get_artists()
assert len(artists) == 2
assert (artists[0].id, artists[0].name, artists[0].album_count) == ("1", "test1", 3)
assert (artists[1].id, artists[1].name, artists[1].album_count) == ("3", "test3", 8)
def test_caching_get_ignored_articles(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_ignored_articles()
# Ingest ignored_articles.
cache_adapter.ingest_new_data(KEYS.IGNORED_ARTICLES, None, {"Foo", "Bar"})
artists = cache_adapter.get_ignored_articles()
assert {"Foo", "Bar"} == artists
# Ingest a new artists list with one of them deleted.
cache_adapter.ingest_new_data(KEYS.IGNORED_ARTICLES, None, {"Foo", "Baz"})
artists = cache_adapter.get_ignored_articles()
assert {"Foo", "Baz"} == artists
def test_caching_get_artist(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_artist("1")
# Simulate the artist details being retrieved from Subsonic.
cache_adapter.ingest_new_data(
KEYS.ARTIST,
"1",
SubsonicAPI.ArtistAndArtistInfo(
id="1",
name="Bar",
album_count=1,
artist_image_url="image",
similar_artists=[
SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
],
biography="this is a bio",
music_brainz_id="mbid",
albums=[SubsonicAPI.Album(id="1", name="Foo", artist_id="1")],
),
)
artist = cache_adapter.get_artist("1")
assert artist.artist_image_url and (
artist.id,
artist.name,
artist.album_count,
artist.artist_image_url,
artist.biography,
artist.music_brainz_id,
) == ("1", "Bar", 1, "image", "this is a bio", "mbid")
assert artist.similar_artists == [
SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
]
assert artist.albums and len(artist.albums) == 1
assert cast(SelectQuery, artist.albums).dicts() == [
SubsonicAPI.Album(id="1", name="Foo")
]
# Simulate "force refreshing" the artist details being retrieved from Subsonic.
cache_adapter.ingest_new_data(
KEYS.ARTIST,
"1",
SubsonicAPI.ArtistAndArtistInfo(
id="1",
name="Foo",
album_count=2,
artist_image_url="image2",
similar_artists=[
SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo(id="E", name="F"),
],
biography="this is a bio2",
music_brainz_id="mbid2",
albums=[
SubsonicAPI.Album(id="1", name="Foo", artist_id="1"),
SubsonicAPI.Album(id="2", name="Bar", artist_id="1"),
],
),
)
artist = cache_adapter.get_artist("1")
assert artist.artist_image_url and (
artist.id,
artist.name,
artist.album_count,
artist.artist_image_url,
artist.biography,
artist.music_brainz_id,
) == ("1", "Foo", 2, "image2", "this is a bio2", "mbid2")
assert artist.similar_artists == [
SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo(id="E", name="F"),
]
assert artist.albums and len(artist.albums) == 2
assert cast(SelectQuery, artist.albums).dicts() == [
SubsonicAPI.Album(id="1", name="Foo"),
SubsonicAPI.Album(id="2", name="Bar"),
]
def test_caching_get_album(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_album("1")
# Simulate the artist details being retrieved from Subsonic.
cache_adapter.ingest_new_data(
KEYS.ALBUM,
"a1",
SubsonicAPI.Album(
id="a1",
name="foo",
cover_art="c",
song_count=2,
year=2020,
duration=timedelta(seconds=31),
play_count=20,
_artist="cool",
artist_id="art1",
songs=MOCK_SUBSONIC_SONGS[:2],
),
)
album = cache_adapter.get_album("a1")
assert album and album.cover_art
assert (
album.id,
album.name,
album.cover_art,
album.song_count,
album.year,
album.play_count,
) == ("a1", "foo", "c", 2, 2020, 20,)
assert album.artist
assert (album.artist.id, album.artist.name) == ("art1", "cool")
assert album.songs
verify_songs(album.songs, MOCK_SUBSONIC_SONGS[:2])
def test_caching_invalidate_artist(cache_adapter: FilesystemAdapter):
# Simulate the artist details being retrieved from Subsonic.
cache_adapter.ingest_new_data(
KEYS.ARTIST,
"artist1",
SubsonicAPI.ArtistAndArtistInfo(
id="artist1",
name="Bar",
album_count=1,
artist_image_url="image",
similar_artists=[
SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
],
biography="this is a bio",
music_brainz_id="mbid",
albums=[
SubsonicAPI.Album(id="1", name="Foo", artist_id="1"),
SubsonicAPI.Album(id="2", name="Bar", artist_id="1"),
],
),
)
cache_adapter.ingest_new_data(
KEYS.ALBUM,
"1",
SubsonicAPI.Album(id="1", name="Foo", artist_id="artist1", cover_art="1"),
)
cache_adapter.ingest_new_data(
KEYS.ALBUM,
"2",
SubsonicAPI.Album(id="2", name="Bar", artist_id="artist1", cover_art="2"),
)
cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "image", MOCK_ALBUM_ART3)
cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "1", MOCK_ALBUM_ART)
cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "2", MOCK_ALBUM_ART2)
stale_artist = cache_adapter.get_artist("artist1")
stale_album_1 = cache_adapter.get_album("1")
stale_album_2 = cache_adapter.get_album("2")
stale_artist_artwork = cache_adapter.get_cover_art_uri("image", "file", size=300)
stale_cover_art_1 = cache_adapter.get_cover_art_uri("1", "file", size=300)
stale_cover_art_2 = cache_adapter.get_cover_art_uri("2", "file", size=300)
cache_adapter.invalidate_data(KEYS.ARTIST, "artist1")
# Test the cascade of cache invalidations.
try:
cache_adapter.get_artist("artist1")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_artist
try:
cache_adapter.get_album("1")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_album_1
try:
cache_adapter.get_album("2")
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_album_2
try:
cache_adapter.get_cover_art_uri("image", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_artist_artwork
try:
cache_adapter.get_cover_art_uri("1", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_cover_art_1
try:
cache_adapter.get_cover_art_uri("2", "file", size=300)
assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e:
assert e.partial_data
assert e.partial_data == stale_cover_art_2
def test_get_music_directory(cache_adapter: FilesystemAdapter):
dir_id = "d1"
with pytest.raises(CacheMissError):
cache_adapter.get_directory(dir_id)
# Simulate the directory details being retrieved from Subsonic.
cache_adapter.ingest_new_data(
KEYS.DIRECTORY,
dir_id,
SubsonicAPI.Directory(
dir_id,
title="foo",
parent_id=None,
_children=[json.loads(s.to_json()) for s in MOCK_SUBSONIC_SONGS[:2]]
+ [
{
"id": "542",
"parent": dir_id,
"isDir": True,
"title": "Crash My Party",
}
],
),
)
directory = cache_adapter.get_directory(dir_id)
assert directory and directory.id == dir_id
assert directory.name == "foo"
assert directory.parent_id == "root"
dir_child, *song_children = directory.children
verify_songs(song_children, MOCK_SUBSONIC_SONGS[:2])
assert dir_child.id == "542"
assert dir_child.parent_id
assert dir_child.name == "Crash My Party"
def test_search(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError):
cache_adapter.get_artist("artist1")
with pytest.raises(CacheMissError):
cache_adapter.get_album("album1")
with pytest.raises(CacheMissError):
cache_adapter.get_song_details("s1")
search_result = SublimeAPI.SearchResult("")
search_result.add_results(
"albums",
[
SubsonicAPI.Album(
id="album1", name="Foo", artist_id="artist1", cover_art="cal1"
),
SubsonicAPI.Album(
id="album2", name="Boo", artist_id="artist1", cover_art="cal2"
),
],
)
search_result.add_results(
"artists",
[
SubsonicAPI.ArtistAndArtistInfo(id="artist1", name="foo", cover_art="car1"),
SubsonicAPI.ArtistAndArtistInfo(
id="artist2", name="better boo", cover_art="car2"
),
],
)
search_result.add_results(
"songs",
[
SubsonicAPI.Song("s1", "amazing boo", cover_art="s1"),
SubsonicAPI.Song("s2", "foo of all foo", cover_art="s2"),
],
)
cache_adapter.ingest_new_data(KEYS.SEARCH_RESULTS, None, search_result)
search_result = cache_adapter.search("foo")
assert [s.title for s in search_result.songs] == ["foo of all foo", "amazing boo"]
assert [a.name for a in search_result.artists] == ["foo", "better boo"]
assert [a.name for a in search_result.albums] == ["Foo", "Boo"]

View File

@@ -0,0 +1,4 @@
The test songs are royalty free music from https://www.fesliyanstudios.com
* test-song.mp3 (originally named Happy_Music-2018-09-18_-_Beautiful_Memories_-_David_Fesliyan.mp3)
* test-song2.mp3 (originally named 2017-03-24_-_Lone_Rider_-_David_Fesliyan.mp3)

View File

@@ -0,0 +1 @@
really not a PNG

View File

@@ -0,0 +1 @@
definitely not a PNG. Stop looking lol

View File

@@ -0,0 +1,6 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0"
}
}

View File

@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.10.2","type":"navidrome","serverVersion":"0.17.0 (13ce218)"}}

View File

@@ -0,0 +1,425 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"album" : {
"id" : "243",
"name" : "What You See Is What You Get",
"artist" : "Luke Combs",
"artistId" : "158",
"coverArt" : "al-243",
"songCount" : 17,
"duration" : 3576,
"created" : "2020-03-27T05:23:06.000Z",
"year" : 2019,
"genre" : "Country",
"song" : [ {
"id" : "771",
"parent" : "754",
"isDir" : false,
"title" : "Beer Never Broke My Heart",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 1,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6251137,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 186,
"bitRate" : 265,
"path" : "Luke Combs/What You See Is What You Get/01 - Beer Never Broke My Heart.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:22:29.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "764",
"parent" : "754",
"isDir" : false,
"title" : "Refrigerator Door",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 2,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6960827,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 204,
"bitRate" : 270,
"path" : "Luke Combs/What You See Is What You Get/02 - Refrigerator Door.mp3",
"isVideo" : false,
"playCount" : 9,
"discNumber" : 1,
"created" : "2020-03-27T05:22:32.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "763",
"parent" : "754",
"isDir" : false,
"title" : "Even Though I'm Leaving",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 3,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 7546594,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 225,
"bitRate" : 266,
"path" : "Luke Combs/What You See Is What You Get/03 - Even Though I'm Leaving.mp3",
"isVideo" : false,
"playCount" : 8,
"discNumber" : 1,
"created" : "2020-03-27T05:22:35.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "772",
"parent" : "754",
"isDir" : false,
"title" : "Lovin' On You",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 4,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6557132,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 195,
"bitRate" : 266,
"path" : "Luke Combs/What You See Is What You Get/04 - Lovin' On You.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:22:38.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "773",
"parent" : "754",
"isDir" : false,
"title" : "Moon Over Mexico",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 5,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6803432,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 204,
"bitRate" : 263,
"path" : "Luke Combs/What You See Is What You Get/05 - Moon Over Mexico.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:22:41.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "770",
"parent" : "754",
"isDir" : false,
"title" : "1, 2 Many",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs feat. Brooks & Dunn",
"track" : 6,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6381054,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 180,
"bitRate" : 279,
"path" : "Luke Combs/What You See Is What You Get/06 - 1, 2 Many.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:22:44.000Z",
"albumId" : "243",
"type" : "music"
}, {
"id" : "766",
"parent" : "754",
"isDir" : false,
"title" : "Blue Collar Boys",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 7,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 7350147,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 221,
"bitRate" : 263,
"path" : "Luke Combs/What You See Is What You Get/07 - Blue Collar Boys.mp3",
"isVideo" : false,
"playCount" : 7,
"discNumber" : 1,
"created" : "2020-03-27T05:22:47.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "762",
"parent" : "754",
"isDir" : false,
"title" : "New Every Day",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 8,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6681802,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 199,
"bitRate" : 265,
"path" : "Luke Combs/What You See Is What You Get/08 - New Every Day.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:22:50.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "774",
"parent" : "754",
"isDir" : false,
"title" : "Reasons",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 9,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 8032132,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 224,
"bitRate" : 284,
"path" : "Luke Combs/What You See Is What You Get/09 - Reasons.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:22:54.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "769",
"parent" : "754",
"isDir" : false,
"title" : "Every Little Bit Helps",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 10,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 8321498,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 248,
"bitRate" : 266,
"path" : "Luke Combs/What You See Is What You Get/10 - Every Little Bit Helps.mp3",
"isVideo" : false,
"playCount" : 11,
"discNumber" : 1,
"created" : "2020-03-27T05:22:58.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "768",
"parent" : "754",
"isDir" : false,
"title" : "Dear Today",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 11,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 5888061,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 219,
"bitRate" : 212,
"path" : "Luke Combs/What You See Is What You Get/11 - Dear Today.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:23:00.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "767",
"parent" : "754",
"isDir" : false,
"title" : "What You See Is What You Get",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 12,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 5843894,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 172,
"bitRate" : 268,
"path" : "Luke Combs/What You See Is What You Get/12 - What You See Is What You Get.mp3",
"isVideo" : false,
"playCount" : 10,
"discNumber" : 1,
"created" : "2020-03-27T05:23:03.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "753",
"parent" : "754",
"isDir" : false,
"title" : "Does To Me",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs feat. Eric Church",
"track" : 13,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 7628720,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 223,
"bitRate" : 270,
"path" : "Luke Combs/What You See Is What You Get/13 - Does To Me.mp3",
"isVideo" : false,
"playCount" : 8,
"discNumber" : 1,
"created" : "2020-03-27T05:23:06.000Z",
"albumId" : "243",
"type" : "music"
}, {
"id" : "759",
"parent" : "754",
"isDir" : false,
"title" : "Angels Workin' Overtime",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 14,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 8895207,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 253,
"bitRate" : 278,
"path" : "Luke Combs/What You See Is What You Get/14 - Angels Workin' Overtime.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:23:10.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "761",
"parent" : "754",
"isDir" : false,
"title" : "All Over Again",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 15,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6955954,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 210,
"bitRate" : 262,
"path" : "Luke Combs/What You See Is What You Get/15 - All Over Again.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:23:13.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "765",
"parent" : "754",
"isDir" : false,
"title" : "Nothing Like You",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 16,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 6261563,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 196,
"bitRate" : 252,
"path" : "Luke Combs/What You See Is What You Get/16 - Nothing Like You.mp3",
"isVideo" : false,
"playCount" : 9,
"discNumber" : 1,
"created" : "2020-03-27T05:23:16.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
}, {
"id" : "760",
"parent" : "754",
"isDir" : false,
"title" : "Better Together",
"album" : "What You See Is What You Get",
"artist" : "Luke Combs",
"track" : 17,
"year" : 2019,
"genre" : "Country",
"coverArt" : "754",
"size" : 5921094,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 217,
"bitRate" : 215,
"path" : "Luke Combs/What You See Is What You Get/17 - Better Together.mp3",
"isVideo" : false,
"playCount" : 0,
"discNumber" : 1,
"created" : "2020-03-27T05:23:19.000Z",
"albumId" : "243",
"artistId" : "158",
"type" : "music"
} ]
}
}
}

View File

@@ -0,0 +1,139 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"artist" : {
"id" : "3",
"name" : "Kane Brown",
"coverArt" : "ar-3",
"albumCount" : 1,
"album" : [ {
"id" : "3",
"name" : "Kane Brown",
"artist" : "Kane Brown",
"artistId" : "3",
"coverArt" : "al-3",
"songCount" : 1,
"duration" : 188,
"created" : "2020-03-27T05:18:42.000Z",
"year" : 2016,
"genre" : "Country"
} ]
}
}
}
=====================================================================
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"artistInfo2" : {
"biography" : "Kane Brown was born and raised in the Chattanooga, Tennessee area by a single mother, moving from Rossville to Fort Oglethorpe and LaFayette in Georgia, finally settling in Red Bank, Tennessee. He attended Lakeview-Fort Oglethorpe High School where he sang in the choir with Lauren Alaina, the runner-up on season 10 of American Idol. He also attended Red Bank, Ridgeland, and Soddy Daisy High Schools, at all of which he was a stand out athlete at football, basketball and track. <a target='_blank' href=\"https://www.last.fm/music/Kane+Brown\">Read more on Last.fm</a>",
"lastFmUrl" : "https://www.last.fm/music/Kane+Brown",
"smallImageUrl" : "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png",
"mediumImageUrl" : "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png",
"largeImageUrl" : "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
"similarArtist" : [ {
"id" : "158",
"name" : "Luke Combs",
"coverArt" : "ar-158",
"albumCount" : 4
}, {
"id" : "22",
"name" : "Brett Young",
"coverArt" : "ar-22",
"albumCount" : 1
}, {
"id" : "14",
"name" : "Thomas Rhett",
"coverArt" : "ar-14",
"albumCount" : 1
}, {
"id" : "59",
"name" : "Luke Bryan",
"coverArt" : "ar-59",
"albumCount" : 1
}, {
"id" : "49",
"name" : "Lee Brice",
"coverArt" : "ar-49",
"albumCount" : 2
}, {
"id" : "110",
"name" : "Scotty McCreery",
"coverArt" : "ar-110",
"albumCount" : 1
}, {
"id" : "168",
"name" : "Brantley Gilbert",
"coverArt" : "ar-168",
"albumCount" : 1
}, {
"id" : "94",
"name" : "Billy Currington",
"coverArt" : "ar-94",
"albumCount" : 1
}, {
"id" : "74",
"name" : "Blake Shelton",
"coverArt" : "ar-74",
"albumCount" : 3
}, {
"id" : "77",
"name" : "Justin Moore",
"coverArt" : "ar-77",
"albumCount" : 1
}, {
"id" : "45",
"name" : "Keith Urban",
"coverArt" : "ar-45",
"albumCount" : 1
}, {
"id" : "107",
"name" : "Eli Young Band",
"coverArt" : "ar-107",
"albumCount" : 1
}, {
"id" : "34",
"name" : "Rodney Atkins",
"coverArt" : "ar-34",
"albumCount" : 2
}, {
"id" : "133",
"name" : "Kenny Chesney",
"coverArt" : "ar-133",
"albumCount" : 2
}, {
"id" : "114",
"name" : "Tim McGraw",
"coverArt" : "ar-114",
"albumCount" : 1
}, {
"id" : "126",
"name" : "Maddie & Tae",
"coverArt" : "ar-126",
"albumCount" : 2
}, {
"id" : "184",
"name" : "Carly Pearce",
"coverArt" : "ar-184",
"albumCount" : 1
}, {
"id" : "135",
"name" : "Brooks & Dunn",
"coverArt" : "ar-135",
"albumCount" : 2
}, {
"id" : "140",
"name" : "Hunter Hayes",
"coverArt" : "ar-140",
"albumCount" : 1
}, {
"id" : "53",
"name" : "Rascal Flatts",
"coverArt" : "ar-53",
"albumCount" : 4
} ]
}
}
}

View File

@@ -0,0 +1,138 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"artist" : {
"id" : "3",
"name" : "Kane Brown",
"coverArt" : "ar-3",
"album" : [ {
"id" : "3",
"name" : "Kane Brown",
"artist" : "Kane Brown",
"artistId" : "3",
"coverArt" : "al-3",
"songCount" : 1,
"duration" : 188,
"created" : "2020-03-27T05:18:42.000Z",
"year" : 2016,
"genre" : "Country"
} ]
}
}
}
=====================================================================
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"artistInfo2" : {
"biography" : "Kane Brown was born and raised in the Chattanooga, Tennessee area by a single mother, moving from Rossville to Fort Oglethorpe and LaFayette in Georgia, finally settling in Red Bank, Tennessee. He attended Lakeview-Fort Oglethorpe High School where he sang in the choir with Lauren Alaina, the runner-up on season 10 of American Idol. He also attended Red Bank, Ridgeland, and Soddy Daisy High Schools, at all of which he was a stand out athlete at football, basketball and track. <a target='_blank' href=\"https://www.last.fm/music/Kane+Brown\">Read more on Last.fm</a>",
"lastFmUrl" : "https://www.last.fm/music/Kane+Brown",
"smallImageUrl" : "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png",
"mediumImageUrl" : "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png",
"largeImageUrl" : "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
"similarArtist" : [ {
"id" : "158",
"name" : "Luke Combs",
"coverArt" : "ar-158",
"albumCount" : 4
}, {
"id" : "22",
"name" : "Brett Young",
"coverArt" : "ar-22",
"albumCount" : 1
}, {
"id" : "14",
"name" : "Thomas Rhett",
"coverArt" : "ar-14",
"albumCount" : 1
}, {
"id" : "59",
"name" : "Luke Bryan",
"coverArt" : "ar-59",
"albumCount" : 1
}, {
"id" : "49",
"name" : "Lee Brice",
"coverArt" : "ar-49",
"albumCount" : 2
}, {
"id" : "110",
"name" : "Scotty McCreery",
"coverArt" : "ar-110",
"albumCount" : 1
}, {
"id" : "168",
"name" : "Brantley Gilbert",
"coverArt" : "ar-168",
"albumCount" : 1
}, {
"id" : "94",
"name" : "Billy Currington",
"coverArt" : "ar-94",
"albumCount" : 1
}, {
"id" : "74",
"name" : "Blake Shelton",
"coverArt" : "ar-74",
"albumCount" : 3
}, {
"id" : "77",
"name" : "Justin Moore",
"coverArt" : "ar-77",
"albumCount" : 1
}, {
"id" : "45",
"name" : "Keith Urban",
"coverArt" : "ar-45",
"albumCount" : 1
}, {
"id" : "107",
"name" : "Eli Young Band",
"coverArt" : "ar-107",
"albumCount" : 1
}, {
"id" : "34",
"name" : "Rodney Atkins",
"coverArt" : "ar-34",
"albumCount" : 2
}, {
"id" : "133",
"name" : "Kenny Chesney",
"coverArt" : "ar-133",
"albumCount" : 2
}, {
"id" : "114",
"name" : "Tim McGraw",
"coverArt" : "ar-114",
"albumCount" : 1
}, {
"id" : "126",
"name" : "Maddie & Tae",
"coverArt" : "ar-126",
"albumCount" : 2
}, {
"id" : "184",
"name" : "Carly Pearce",
"coverArt" : "ar-184",
"albumCount" : 1
}, {
"id" : "135",
"name" : "Brooks & Dunn",
"coverArt" : "ar-135",
"albumCount" : 2
}, {
"id" : "140",
"name" : "Hunter Hayes",
"coverArt" : "ar-140",
"albumCount" : 1
}, {
"id" : "53",
"name" : "Rascal Flatts",
"coverArt" : "ar-53",
"albumCount" : 4
} ]
}
}
}

View File

@@ -0,0 +1,138 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"artist" : {
"id" : "3",
"name" : "Kane Brown",
"coverArt" : "ar-3",
"album" : [ {
"id" : "3",
"name" : "Kane Brown",
"artist" : "Kane Brown",
"artistId" : "3",
"coverArt" : "al-3",
"songCount" : 1,
"duration" : 188,
"created" : "2020-03-27T05:18:42.000Z",
"year" : 2016,
"genre" : "Country"
} ]
}
}
}
=====================================================================
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"artistInfo2" : {
"biography" : "Kane Brown was born and raised in the Chattanooga, Tennessee area by a single mother, moving from Rossville to Fort Oglethorpe and LaFayette in Georgia, finally settling in Red Bank, Tennessee. He attended Lakeview-Fort Oglethorpe High School where he sang in the choir with Lauren Alaina, the runner-up on season 10 of American Idol. He also attended Red Bank, Ridgeland, and Soddy Daisy High Schools, at all of which he was a stand out athlete at football, basketball and track. <a target='_blank' href=\"https://www.last.fm/music/Kane+Brown\">Read more on Last.fm</a>",
"lastFmUrl" : "https://www.last.fm/music/Kane+Brown",
"smallImageUrl" : "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png",
"mediumImageUrl" : "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png",
"largeImageUrl" : "http://entertainermag.com/wp-content/uploads/2017/04/Kane-Brown-Web-Optimized.jpg",
"similarArtist" : [ {
"id" : "158",
"name" : "Luke Combs",
"coverArt" : "ar-158",
"albumCount" : 4
}, {
"id" : "22",
"name" : "Brett Young",
"coverArt" : "ar-22",
"albumCount" : 1
}, {
"id" : "14",
"name" : "Thomas Rhett",
"coverArt" : "ar-14",
"albumCount" : 1
}, {
"id" : "59",
"name" : "Luke Bryan",
"coverArt" : "ar-59",
"albumCount" : 1
}, {
"id" : "49",
"name" : "Lee Brice",
"coverArt" : "ar-49",
"albumCount" : 2
}, {
"id" : "110",
"name" : "Scotty McCreery",
"coverArt" : "ar-110",
"albumCount" : 1
}, {
"id" : "168",
"name" : "Brantley Gilbert",
"coverArt" : "ar-168",
"albumCount" : 1
}, {
"id" : "94",
"name" : "Billy Currington",
"coverArt" : "ar-94",
"albumCount" : 1
}, {
"id" : "74",
"name" : "Blake Shelton",
"coverArt" : "ar-74",
"albumCount" : 3
}, {
"id" : "77",
"name" : "Justin Moore",
"coverArt" : "ar-77",
"albumCount" : 1
}, {
"id" : "45",
"name" : "Keith Urban",
"coverArt" : "ar-45",
"albumCount" : 1
}, {
"id" : "107",
"name" : "Eli Young Band",
"coverArt" : "ar-107",
"albumCount" : 1
}, {
"id" : "34",
"name" : "Rodney Atkins",
"coverArt" : "ar-34",
"albumCount" : 2
}, {
"id" : "133",
"name" : "Kenny Chesney",
"coverArt" : "ar-133",
"albumCount" : 2
}, {
"id" : "114",
"name" : "Tim McGraw",
"coverArt" : "ar-114",
"albumCount" : 1
}, {
"id" : "126",
"name" : "Maddie & Tae",
"coverArt" : "ar-126",
"albumCount" : 2
}, {
"id" : "184",
"name" : "Carly Pearce",
"coverArt" : "ar-184",
"albumCount" : 1
}, {
"id" : "135",
"name" : "Brooks & Dunn",
"coverArt" : "ar-135",
"albumCount" : 2
}, {
"id" : "140",
"name" : "Hunter Hayes",
"coverArt" : "ar-140",
"albumCount" : 1
}, {
"id" : "53",
"name" : "Rascal Flatts",
"coverArt" : "ar-53",
"albumCount" : 4
} ]
}
}
}

View File

@@ -0,0 +1,68 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"artists": {
"ignoredArticles": "The El La Los Las Le Les",
"index": [
{
"name": "A",
"artist": [
{
"id": "102",
"name": "Adele",
"coverArt": "ar-102",
"albumCount": 1
},
{
"id": "122",
"name": "Austin French",
"coverArt": "ar-122",
"albumCount": 1
},
{
"id": "75",
"name": "The Afters",
"coverArt": "ar-75",
"albumCount": 2
}
]
},
{
"name": "B",
"artist": [
{
"id": "95",
"name": "The Band Perry",
"coverArt": "ar-95",
"albumCount": 1
},
{
"id": "41",
"name": "Basshunter",
"coverArt": "ar-41",
"albumCount": 1
}
]
},
{
"name": "X-Z",
"artist": [
{
"id": "154",
"name": "Zac Brown Band",
"coverArt": "ar-154",
"albumCount": 3
},
{
"id": "25",
"name": "Zach Williams",
"coverArt": "ar-25",
"albumCount": 1
}
]
}
]
}
}
}

View File

@@ -0,0 +1,58 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"artists": {
"ignoredArticles": "The El La Los Las Le Les",
"index": [
{
"name": "A",
"artist": [
{
"id": "102",
"name": "Adele",
"coverArt": "ar-102",
"albumCount": 1
},
{
"id": "75",
"name": "The Afters",
"coverArt": "ar-75",
"albumCount": 2
},
{
"id": "95",
"name": "The Band Perry",
"coverArt": "ar-95",
"albumCount": 1
},
{
"id": "41",
"name": "Basshunter",
"coverArt": "ar-41",
"albumCount": 1
},
{
"id": "25",
"name": "Zach Williams",
"coverArt": "ar-25",
"albumCount": 1
},
{
"id": "122",
"name": "Austin French",
"coverArt": "ar-122",
"albumCount": 1
},
{
"id": "154",
"name": "Zac Brown Band",
"coverArt": "ar-154",
"albumCount": 3
}
]
}
]
}
}
}

View File

@@ -0,0 +1,20 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"genres": {
"genre": [
{
"songCount": 124,
"albumCount": 77,
"value": "Country"
},
{
"songCount": 31,
"albumCount": 25,
"value": "Pop"
}
]
}
}
}

View File

@@ -0,0 +1,55 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"indexes": {
"lastModified": 1588577415000,
"ignoredArticles": "The El La Los Las Le Les",
"index": [
{
"name": "A",
"artist": [
{
"id": "73",
"name": "The Afters"
},
{
"id": "100",
"name": "Adele"
},
{
"id": "120",
"name": "Austin French"
}
]
},
{
"name": "B",
"artist": [
{
"id": "93",
"name": "The Band Perry"
},
{
"id": "41",
"name": "Basshunter"
}
]
},
{
"name": "X-Z",
"artist": [
{
"id": "155",
"name": "Zac Brown Band"
},
{
"id": "25",
"name": "Zach Williams"
}
]
}
]
}
}
}

View File

@@ -0,0 +1,24 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"directory" : {
"id" : "60",
"name" : "Luke Bryan",
"playCount" : 0,
"child" : [ {
"id" : "542",
"parent" : "60",
"isDir" : true,
"title" : "Crash My Party",
"album" : "Crash My Party",
"artist" : "Luke Bryan",
"year" : 2013,
"genre" : "Country",
"coverArt" : "542",
"playCount" : 48,
"created" : "2020-03-27T05:27:57.000Z"
} ]
}
}
}

View File

@@ -0,0 +1,138 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.15.0",
"playQueue" : {
"current" : 2823,
"position" : 98914,
"username" : "sumner",
"changed" : "2020-05-12T05:16:32.114Z",
"changedBy" : "Sublime Music",
"entry" : [ {
"id" : "431",
"parent" : "432",
"isDir" : false,
"title" : "Despacito",
"album" : "Despacito",
"artist" : "Peter Bence",
"track" : 1,
"year" : 2017,
"genre" : "Classical",
"coverArt" : "432",
"size" : 6933128,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 205,
"bitRate" : 266,
"path" : "Peter Bence/Despacito/01 - Despacito.mp3",
"isVideo" : false,
"playCount" : 61,
"discNumber" : 1,
"created" : "2020-03-27T05:26:51.000Z",
"albumId" : "7",
"artistId" : "5",
"type" : "music"
}, {
"id" : "2823",
"parent" : "2824",
"isDir" : false,
"title" : "Guitar Sound",
"album" : "Disorganized Fun",
"artist" : "Ronald Jenkees",
"track" : 3,
"year" : 2009,
"genre" : "Dance & DJ",
"coverArt" : "2824",
"size" : 13451121,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 420,
"bitRate" : 249,
"path" : "Ronald Jenkees/Disorganized Fun/03 - Guitar Sound.mp3",
"isVideo" : false,
"playCount" : 349,
"discNumber" : 1,
"created" : "2020-04-07T17:01:08.000Z",
"albumId" : "275",
"artistId" : "183",
"type" : "music"
}, {
"id" : "501",
"parent" : "502",
"isDir" : false,
"title" : "Raver's Fantasy (Radio Mix)",
"album" : "Raver's Fantasy - EP",
"artist" : "Tune Up!",
"track" : 1,
"year" : 2007,
"genre" : "Dance",
"coverArt" : "502",
"size" : 7492493,
"contentType" : "audio/mp4",
"suffix" : "m4a",
"transcodedContentType" : "audio/mpeg",
"transcodedSuffix" : "mp3",
"duration" : 209,
"bitRate" : 256,
"path" : "Tune Up!/Raver's Fantasy - EP/01 Raver's Fantasy (Radio Mix).m4a",
"isVideo" : false,
"playCount" : 27,
"discNumber" : 1,
"created" : "2020-03-27T05:31:26.000Z",
"albumId" : "62",
"artistId" : "46",
"type" : "music"
}, {
"id" : "491",
"parent" : "492",
"isDir" : false,
"title" : "Dota",
"album" : "Now You're Gone",
"artist" : "Basshunter",
"track" : 14,
"year" : 2013,
"genre" : "Dance & DJ",
"coverArt" : "492",
"size" : 7070471,
"contentType" : "audio/mpeg",
"suffix" : "mp3",
"duration" : 200,
"bitRate" : 271,
"path" : "Basshunter/Now You're Gone/14 - Dota.mp3",
"isVideo" : false,
"playCount" : 18,
"discNumber" : 1,
"created" : "2020-03-27T05:12:34.000Z",
"albumId" : "52",
"artistId" : "41",
"type" : "music"
}, {
"id" : "501",
"parent" : "502",
"isDir" : false,
"title" : "Raver's Fantasy (Radio Mix)",
"album" : "Raver's Fantasy - EP",
"artist" : "Tune Up!",
"track" : 1,
"year" : 2007,
"genre" : "Dance",
"coverArt" : "502",
"size" : 7492493,
"contentType" : "audio/mp4",
"suffix" : "m4a",
"transcodedContentType" : "audio/mpeg",
"transcodedSuffix" : "mp3",
"duration" : 209,
"bitRate" : 256,
"path" : "Tune Up!/Raver's Fantasy - EP/01 Raver's Fantasy (Radio Mix).m4a",
"isVideo" : false,
"playCount" : 27,
"discNumber" : 1,
"created" : "2020-03-27T05:31:26.000Z",
"albumId" : "62",
"artistId" : "46",
"type" : "music"
} ]
}
}
}

Some files were not shown because too many files have changed in this diff Show More