Adding a bunch of flake8 extensions and working through the errors

This commit is contained in:
Sumner Evans
2020-02-22 17:03:37 -07:00
parent 91c2f408b3
commit 2a0c480d4b
30 changed files with 1979 additions and 618 deletions

16
Pipfile
View File

@@ -4,21 +4,25 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages] [dev-packages]
docutils = "*"
flake8 = "*" flake8 = "*"
flake8-annotations = "*"
flake8-comprehensions = "*"
flake8-pep3101 = "*"
flake8-print = "*"
graphviz = "*"
jedi = "*"
lxml = "*"
mypy = "*" mypy = "*"
yapf = "*"
pytest = "*" pytest = "*"
pytest-cov = "*" pytest-cov = "*"
docutils = "*"
lxml = "*"
jedi = "*"
rope = "*" rope = "*"
rst2html5 = "*" rst2html5 = "*"
graphviz = "*"
sphinx = "*" sphinx = "*"
sphinx-rtd-theme = "*"
sphinx-autodoc-typehints = "*" sphinx-autodoc-typehints = "*"
sphinx-rtd-theme = "*"
termcolor = "*" termcolor = "*"
yapf = "*"
[packages] [packages]
sublime-music = {editable = true,path = "."} sublime-music = {editable = true,path = "."}

45
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "c16627b97b66d2ad7016bd43428e26f5f29836ba28eb797d27c4cc80f8a70a99" "sha256": "1697c1d3c4480dbec759d96e80b367e508d813cb2183a1c6226f56ee0be8fac6"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -383,6 +383,37 @@
"index": "pypi", "index": "pypi",
"version": "==3.7.9" "version": "==3.7.9"
}, },
"flake8-annotations": {
"hashes": [
"sha256:19a6637a5da1bb7ea7948483ca9e2b9e15b213e687e7bf5ff8c1bfc91c185006",
"sha256:bb033b72cdd3a2b0a530bbdf2081f12fbea7d70baeaaebb5899723a45f424b8e"
],
"index": "pypi",
"version": "==2.0.0"
},
"flake8-comprehensions": {
"hashes": [
"sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2",
"sha256:e7db586bb6eb95afdfd87ed244c90e57ae1352db8ef0ad3012fca0200421e5df"
],
"index": "pypi",
"version": "==3.2.2"
},
"flake8-pep3101": {
"hashes": [
"sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512",
"sha256:a5dae1caca1243b2b40108dce926d97cf5a9f52515c4a4cbb1ffe1ca0c54e343"
],
"index": "pypi",
"version": "==1.3.0"
},
"flake8-print": {
"hashes": [
"sha256:324f9e59a522518daa2461bacd7f82da3c34eb26a4314c2a54bd493f8b394a68"
],
"index": "pypi",
"version": "==3.1.4"
},
"genshi": { "genshi": {
"hashes": [ "hashes": [
"sha256:5e92e278ca1ea395349a451d54fc81dc3c1b543c48939a15bd36b7b3335e1560", "sha256:5e92e278ca1ea395349a451d54fc81dc3c1b543c48939a15bd36b7b3335e1560",
@@ -659,11 +690,11 @@
}, },
"sphinx": { "sphinx": {
"hashes": [ "hashes": [
"sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f", "sha256:776ff8333181138fae52df65be733127539623bb46cc692e7fa0fcfc80d7aa88",
"sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9" "sha256:ca762da97c3b5107cbf0ab9e11d3ec7ab8d3c31377266fd613b962ed971df709"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.4.2" "version": "==2.4.3"
}, },
"sphinx-autodoc-typehints": { "sphinx-autodoc-typehints": {
"hashes": [ "hashes": [
@@ -697,10 +728,10 @@
}, },
"sphinxcontrib-htmlhelp": { "sphinxcontrib-htmlhelp": {
"hashes": [ "hashes": [
"sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f",
"sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"
], ],
"version": "==1.0.2" "version": "==1.0.3"
}, },
"sphinxcontrib-jsmath": { "sphinxcontrib-jsmath": {
"hashes": [ "hashes": [

View File

@@ -1,14 +1,16 @@
#! /usr/bin/env python3 #! /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 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 Subsonic REST API XSD file. It then uses that graph to generate code which
represents those API objects in Python. represents those API objects in Python.
""" """
import re import re
from collections import defaultdict
from typing import cast, Dict, DefaultDict, Set, Match, Tuple, List
import sys import sys
from collections import defaultdict
from typing import DefaultDict, Dict, List, Set, Tuple
from graphviz import Digraph from graphviz import Digraph
from lxml import etree from lxml import etree
@@ -25,12 +27,13 @@ primitive_translation_map = {
} }
def render_digraph(graph, filename): def render_digraph(graph: DefaultDict[str, Set[str]], filename: str):
""" """
Renders a graph of form {'node_name': iterable(node_name)} to ``filename``. Render a graph of the form {'node_name': iterable(node_name)} to
``filename``.
""" """
g = Digraph('G', filename=f'/tmp/{filename}', format='png') g = Digraph('G', filename=f'/tmp/{filename}', format='png')
for type_, deps in dependency_graph.items(): for type_, deps in graph.items():
g.node(type_) g.node(type_)
for dep in deps: for dep in deps:
@@ -39,21 +42,27 @@ def render_digraph(graph, filename):
g.render() g.render()
def primitive_translate(type_str): def primitive_translate(type_str: str) -> str:
# Translate the primitive values, but default to the actual value. # Translate the primitive values, but default to the actual value.
return primitive_translation_map.get(type_str, type_str) return primitive_translation_map.get(type_str, type_str)
def extract_type(type_str): def extract_type(type_str: str) -> str:
return primitive_translate( match = element_type_re.match(type_str)
cast(Match, element_type_re.match(type_str)).group(1)) 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): def extract_tag_type(tag_type_str: str) -> str:
return cast(Match, tag_type_re.match(tag_type_str)).group(1) 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) -> Tuple[Set[str], Dict[str, str]]: 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 Return the types which ``xs_el`` depends on as well as the type of the
object for embedding in other objects. object for embedding in other objects.
@@ -162,7 +171,7 @@ def get_dependencies(xs_el) -> Tuple[Set[str], Dict[str, str]]:
# Check arguments. # Check arguments.
# ============================================================================= # =============================================================================
if len(sys.argv) < 3: if len(sys.argv) < 3:
print(f'Usage: {sys.argv[0]} <schema_file> <output_file>.') print(f'Usage: {sys.argv[0]} <schema_file> <output_file>.') # noqa: T001
sys.exit(1) sys.exit(1)
schema_file, output_file = sys.argv[1:] schema_file, output_file = sys.argv[1:]
@@ -201,7 +210,7 @@ seen: Set[str] = set()
i = 0 i = 0
def dfs(g, el): def dfs(g: DefaultDict[str, Set[str]], el: str):
global i global i
if el in seen: if el in seen:
return return
@@ -224,7 +233,7 @@ output_order.remove('subsonic-response')
# ============================================================================= # =============================================================================
def generate_class_for_type(type_name): def generate_class_for_type(type_name: str) -> str:
fields = type_fields[type_name] fields = type_fields[type_name]
code = ['', ''] code = ['', '']
@@ -262,12 +271,12 @@ def generate_class_for_type(type_name):
# Auto-generated __eq__ and __hash__ functions if there's an ID field. # Auto-generated __eq__ and __hash__ functions if there's an ID field.
if not is_enum and has_properties and 'id' in fields: if not is_enum and has_properties and 'id' in fields:
code.append('') code.append('')
code.append(indent_str.format('def __eq__(self, other):')) code.append(indent_str.format('def __eq__(self, other: Any) -> bool:'))
code.append(indent_str.format(' return hash(self) == hash(other)')) code.append(indent_str.format(' return hash(self) == hash(other)'))
hash_name = inherits or type_name hash_name = inherits or type_name
code.append('') code.append('')
code.append(indent_str.format('def __hash__(self):')) code.append(indent_str.format('def __hash__(self) -> int:'))
code.append( code.append(
indent_str.format(f" return hash(f'{hash_name}.{{self.id}}')")) indent_str.format(f" return hash(f'{hash_name}.{{self.id}}')"))
@@ -286,8 +295,9 @@ with open(output_file, 'w+') as outfile:
'"""', '"""',
'', '',
'from datetime import datetime', 'from datetime import datetime',
'from typing import List',
'from enum import Enum', 'from enum import Enum',
'from typing import Any, List',
'',
'from sublime.server.api_object import APIObject', 'from sublime.server.api_object import APIObject',
*map(generate_class_for_type, output_order), *map(generate_class_for_type, output_order),
]) + '\n') ]) + '\n')

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.16.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,640 @@
<?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.16.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="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="artistImageUrl" type="xs:string" use="optional"/> <!-- Added in 1.16.1 -->
<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="artistImageUrl" type="xs:string" use="optional"/> <!-- Added in 1.16.1 -->
<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

@@ -1,27 +1,21 @@
#! /usr/bin/env python #! /usr/bin/env python
import sys
import re import re
import sys
from pathlib import Path from pathlib import Path
from termcolor import cprint from termcolor import cprint
print_re = re.compile(r'print\(.*\)')
todo_re = re.compile(r'#\s*TODO:?\s*') todo_re = re.compile(r'#\s*TODO:?\s*')
accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)') accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)')
def check_file(path): def check_file(path: Path) -> bool:
print(f'Checking {path.absolute()}...') 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 print_re.search(line) and '# allowprint' not in line:
cprint(f'{i}: {line}', 'red', end='', attrs=['bold'])
valid = False
if todo_re.search(line) and not accounted_for_todo.search(line): if todo_re.search(line) and not accounted_for_todo.search(line):
cprint(f'{i}: {line}', 'red', end='', attrs=['bold']) cprint(f'{i}: {line}', 'red', end='', attrs=['bold'])
valid = False valid = False
@@ -33,6 +27,6 @@ def check_file(path):
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() print() # noqa: T001
sys.exit(0 if valid else 1) sys.exit(0 if valid else 1)

View File

@@ -1,6 +1,9 @@
[flake8] [flake8]
ignore = E402, W503 ignore = E402, W503, ANN002, ANN003, ANN101, ANN102, ANN204
exclude = .git,__pycache__,build,dist exclude = .git,__pycache__,build,dist
suppress-none-returning = True
application-import-names = sublime
import-order-style = edited
[mypy-gi] [mypy-gi]
ignore_missing_imports = True ignore_missing_imports = True

View File

@@ -1,14 +1,14 @@
#! /usr/bin/env python3 #! /usr/bin/env python3
import os
import argparse import argparse
import logging import logging
import os
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
from .app import SublimeMusicApp from sublime.app import SublimeMusicApp
def main(): def main():
@@ -39,7 +39,7 @@ def main():
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__}') # allowprint 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)

View File

@@ -1,26 +1,25 @@
import os
import logging import logging
import math import math
import os
import random import random
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
gi.require_version('Notify', '0.7') gi.require_version('Notify', '0.7')
from gi.repository import Gdk, Gio, GLib, Gtk, Notify, GdkPixbuf from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Notify
from .ui.main import MainWindow
from .ui.configure_servers import ConfigureServersDialog
from .ui.settings import SettingsDialog
from .dbus_manager import DBusManager, dbus_propagate
from .state_manager import ApplicationState, RepeatType
from .cache_manager import CacheManager from .cache_manager import CacheManager
from .dbus_manager import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, PlayerEvent
from .server.api_objects import Child, Directory from .server.api_objects import Child, Directory
from .players import PlayerEvent, MPVPlayer, ChromecastPlayer from .state_manager import ApplicationState, RepeatType
from .ui.configure_servers import ConfigureServersDialog
from .ui.main import MainWindow
from .ui.settings import SettingsDialog
class SublimeMusicApp(Gtk.Application): class SublimeMusicApp(Gtk.Application):
def __init__(self, config_file): def __init__(self, config_file: str):
super().__init__(application_id="com.sumnerevans.sublimemusic") super().__init__(application_id="com.sumnerevans.sublimemusic")
Notify.init('Sublime Music') Notify.init('Sublime Music')
@@ -912,11 +911,12 @@ class SublimeMusicApp(Gtk.Application):
song_idx = self.state.play_queue.index(song.id) song_idx = self.state.play_queue.index(song.id)
prefetch_idxs = [] prefetch_idxs = []
for i in range(self.state.config.prefetch_amount): for i in range(self.state.config.prefetch_amount):
prefetch_idx = song_idx + 1 + i prefetch_idx: int = song_idx + 1 + i
play_queue_len = len(self.state.play_queue) play_queue_len: int = len(self.state.play_queue)
if (self.state.repeat_type == RepeatType.REPEAT_QUEUE if (self.state.repeat_type == RepeatType.REPEAT_QUEUE
or prefetch_idx < play_queue_len): or prefetch_idx < play_queue_len):
prefetch_idxs.append(prefetch_idx % play_queue_len) prefetch_idxs.append(
prefetch_idx % play_queue_len) # noqa: S001
CacheManager.batch_download_songs( CacheManager.batch_download_songs(
[self.state.play_queue[i] for i in prefetch_idxs], [self.state.play_queue[i] for i in prefetch_idxs],
before_download=lambda: GLib.idle_add(self.update_window), before_download=lambda: GLib.idle_add(self.update_window),

View File

@@ -1,57 +1,50 @@
import os
import logging
import glob import glob
import itertools
import threading
import shutil
import json
import hashlib import hashlib
import itertools
from functools import lru_cache import json
import logging
import os
import shutil
import threading
from collections import defaultdict from collections import defaultdict
from time import sleep from concurrent.futures import Future, ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, Future
from enum import EnumMeta, Enum
from datetime import datetime from datetime import datetime
from enum import Enum, EnumMeta
from functools import lru_cache
from pathlib import Path from pathlib import Path
from time import sleep
from typing import ( from typing import (
Any, Any,
Callable,
DefaultDict,
Generic, Generic,
Iterable, Iterable,
List, List,
Optional, Optional,
Union,
Callable,
Set, Set,
DefaultDict,
Tuple, Tuple,
TypeVar, TypeVar,
Union,
) )
import requests import requests
from fuzzywuzzy import fuzz from fuzzywuzzy import fuzz
from .config import AppConfiguration, ServerConfiguration from .config import AppConfiguration, ServerConfiguration
from .server import Server from .server import Server
from .server.api_object import APIObject from .server.api_object import APIObject
from .server.api_objects import ( from .server.api_objects import (
Playlist, AlbumID3,
PlaylistWithSongs, AlbumWithSongsID3,
Child,
Genre,
# Non-ID3 versions
Artist, Artist,
Directory,
# ID3 versions
ArtistID3, ArtistID3,
ArtistInfo2, ArtistInfo2,
ArtistWithAlbumsID3, ArtistWithAlbumsID3,
AlbumID3, Child,
AlbumWithSongsID3, Directory,
Genre,
Playlist,
PlaylistWithSongs,
) )
@@ -60,7 +53,7 @@ class Singleton(type):
Metaclass for :class:`CacheManager` so that it can be used like a Metaclass for :class:`CacheManager` so that it can be used like a
singleton. singleton.
""" """
def __getattr__(cls, name): def __getattr__(cls, name: str) -> Any:
if not CacheManager._instance: if not CacheManager._instance:
return None return None
# If the cache has a function to do the thing we want, use it. If # If the cache has a function to do the thing we want, use it. If
@@ -82,7 +75,7 @@ class SongCacheStatus(Enum):
@lru_cache(maxsize=8192) @lru_cache(maxsize=8192)
def similarity_ratio(query: str, string: str): def similarity_ratio(query: str, string: str) -> int:
""" """
Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and
the given ``string``. the given ``string``.
@@ -96,17 +89,20 @@ def similarity_ratio(query: str, string: str):
return fuzz.partial_ratio(query.lower(), string.lower()) return fuzz.partial_ratio(query.lower(), string.lower())
S = TypeVar('S')
class SearchResult: class SearchResult:
""" """
An object representing the aggregate results of a search which can include An object representing the aggregate results of a search which can include
both server and local results. both server and local results.
""" """
_artist: Set[Union[Artist, ArtistID3]] = set() _artist: Set[ArtistID3] = set()
_album: Set[Union[Child, AlbumID3]] = set() _album: Set[AlbumID3] = set()
_song: Set[Child] = set() _song: Set[Child] = set()
_playlist: Set[Playlist] = set() _playlist: Set[Playlist] = set()
def __init__(self, query): def __init__(self, query: str):
self.query = query self.query = query
def add_results(self, result_type: str, results: Iterable): def add_results(self, result_type: str, results: Iterable):
@@ -124,13 +120,17 @@ class SearchResult:
getattr(getattr(self, member, set()), 'union')(set(results)), getattr(getattr(self, member, set()), 'union')(set(results)),
) )
def _to_result(self, it, transform): def _to_result(
self,
it: Iterable[S],
transform: Callable[[S], str],
) -> List[S]:
all_results = sorted( all_results = sorted(
((similarity_ratio(self.query, transform(x)), x) for x in it), ((similarity_ratio(self.query, transform(x)), x) for x in it),
key=lambda rx: rx[0], key=lambda rx: rx[0],
reverse=True, reverse=True,
) )
result = [] result: List[S] = []
for ratio, x in all_results: for ratio, x in all_results:
if ratio > 60 and len(result) < 20: if ratio > 60 and len(result) < 20:
result.append(x) result.append(x)
@@ -181,9 +181,9 @@ class CacheManager(metaclass=Singleton):
around a Future, but it can also resolve immediately if the data around a Future, but it can also resolve immediately if the data
already exists. already exists.
""" """
data = None data: Optional[T] = None
future = None future: Optional[Future] = None
on_cancel = None on_cancel: Optional[Callable[[], None]] = None
@staticmethod @staticmethod
def from_data(data: T) -> 'CacheManager.Result[T]': def from_data(data: T) -> 'CacheManager.Result[T]':
@@ -193,10 +193,10 @@ class CacheManager(metaclass=Singleton):
@staticmethod @staticmethod
def from_server( def from_server(
download_fn, download_fn: Callable[[], T],
before_download=None, before_download: Callable[[], Any] = None,
after_download=None, after_download: Callable[[T], Any] = None,
on_cancel=None, on_cancel: Callable[[], Any] = None,
) -> 'CacheManager.Result[T]': ) -> 'CacheManager.Result[T]':
result: 'CacheManager.Result[T]' = CacheManager.Result() result: 'CacheManager.Result[T]' = CacheManager.Result()
@@ -208,9 +208,9 @@ class CacheManager(metaclass=Singleton):
result.future = CacheManager.executor.submit(future_fn) result.future = CacheManager.executor.submit(future_fn)
result.on_cancel = on_cancel result.on_cancel = on_cancel
if after_download: if after_download is not None:
result.future.add_done_callback( result.future.add_done_callback(
lambda f: after_download(f.result())) lambda f: after_download and after_download(f.result()))
return result return result
@@ -224,8 +224,8 @@ class CacheManager(metaclass=Singleton):
'CacheManager.Result did not have either a data or future ' 'CacheManager.Result did not have either a data or future '
'member.') 'member.')
def add_done_callback(self, fn, *args): def add_done_callback(self, fn: Callable, *args):
if self.is_future: if self.future is not None:
self.future.add_done_callback(fn, *args) self.future.add_done_callback(fn, *args)
else: else:
# Run the function immediately if it's not a future. # Run the function immediately if it's not a future.
@@ -668,7 +668,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data( return CacheManager.Result.from_data(
self.cache[cache_name][artist_id]) self.cache[cache_name][artist_id])
def after_download(artist_info): def after_download(artist_info: Optional[ArtistInfo2]):
if not artist_info: if not artist_info:
return return
@@ -677,7 +677,8 @@ class CacheManager(metaclass=Singleton):
self.save_cache_info() self.save_cache_info()
return CacheManager.Result.from_server( return CacheManager.Result.from_server(
lambda: self.server.get_artist_info2(id=artist_id), lambda:
(self.server.get_artist_info2(id=artist_id) or ArtistInfo2()),
before_download=before_download, before_download=before_download,
after_download=after_download, after_download=after_download,
) )
@@ -881,9 +882,8 @@ class CacheManager(metaclass=Singleton):
) -> 'CacheManager.Result[Optional[str]]': ) -> 'CacheManager.Result[Optional[str]]':
if id is None: if id is None:
art_path = 'ui/images/default-album-art.png' art_path = 'ui/images/default-album-art.png'
return CacheManager.Result.from_data(str( return CacheManager.Result.from_data(
Path(__file__).parent.joinpath(art_path) str(Path(__file__).parent.joinpath(art_path)))
))
return self.return_cached_or_download( return self.return_cached_or_download(
f'cover_art/{id}_{size}', f'cover_art/{id}_{size}',
lambda: self.server.get_cover_art(id, str(size)), lambda: self.server.get_cover_art(id, str(size)),
@@ -971,10 +971,10 @@ class CacheManager(metaclass=Singleton):
query, query,
search_callback: Callable[[SearchResult, bool], None], search_callback: Callable[[SearchResult, bool], None],
before_download: Callable[[], None] = lambda: None, before_download: Callable[[], None] = lambda: None,
): ) -> 'CacheManager.Result':
if query == '': if query == '':
search_callback(SearchResult(''), True) search_callback(SearchResult(''), True)
return return CacheManager.from_data(None)
before_download() before_download()
@@ -1033,9 +1033,7 @@ class CacheManager(metaclass=Singleton):
cancelled = True cancelled = True
return CacheManager.Result.from_server( return CacheManager.Result.from_server(
do_search, do_search, on_cancel=on_cancel)
on_cancel=on_cancel,
)
def get_cached_status(self, song: Child) -> SongCacheStatus: def get_cached_status(self, song: Child) -> SongCacheStatus:
cache_path = self.calculate_abs_path(song.path) cache_path = self.calculate_abs_path(song.path)
@@ -1055,7 +1053,11 @@ class CacheManager(metaclass=Singleton):
raise Exception('Do not instantiate the CacheManager.') raise Exception('Do not instantiate the CacheManager.')
@staticmethod @staticmethod
def reset(app_config, server_config, current_ssids: Set[str]): def reset(
app_config: AppConfiguration,
server_config: ServerConfiguration,
current_ssids: Set[str],
):
CacheManager._instance = CacheManager.__CacheManagerInternal( CacheManager._instance = CacheManager.__CacheManagerInternal(
app_config, app_config,
server_config, server_config,

View File

@@ -1,8 +1,8 @@
import os
import logging import logging
import keyring import os
from typing import Any, Dict, List, Optional
from typing import List, Optional import keyring
class ServerConfiguration: class ServerConfiguration:
@@ -17,14 +17,14 @@ class ServerConfiguration:
def __init__( def __init__(
self, self,
name='Default', name: str = 'Default',
server_address='http://yourhost', server_address: str = 'http://yourhost',
local_network_address='', local_network_address: str = '',
local_network_ssid='', local_network_ssid: str = '',
username='', username: str = '',
password='', password: str = '',
sync_enabled=True, sync_enabled: bool = True,
disable_cert_verify=False, disable_cert_verify: bool = False,
): ):
self.name = name self.name = name
self.server_address = server_address self.server_address = server_address
@@ -43,7 +43,7 @@ class ServerConfiguration:
pass pass
@property @property
def password(self): def password(self) -> str:
return keyring.get_password( return keyring.get_password(
'com.sumnerevans.SublimeMusic', 'com.sumnerevans.SublimeMusic',
f'{self.username}@{self.server_address}', f'{self.username}@{self.server_address}',
@@ -64,7 +64,7 @@ class AppConfiguration:
version: int = 2 version: int = 2
serve_over_lan: bool = True serve_over_lan: bool = True
def to_json(self): def to_json(self) -> Dict[str, Any]:
exclude = ('servers') exclude = ('servers')
json_object = { json_object = {
k: getattr(self, k) k: getattr(self, k)
@@ -88,7 +88,7 @@ class AppConfiguration:
self.version = 2 self.version = 2
@property @property
def cache_location(self): def cache_location(self) -> str:
if (hasattr(self, '_cache_location') if (hasattr(self, '_cache_location')
and self._cache_location is not None and self._cache_location is not None
and self._cache_location != ''): and self._cache_location != ''):

View File

@@ -8,8 +8,8 @@ from typing import Dict
from deepdiff import DeepDiff from deepdiff import DeepDiff
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
from .state_manager import RepeatType
from .cache_manager import CacheManager from .cache_manager import CacheManager
from .state_manager import RepeatType
def dbus_propagate(param_self=None): def dbus_propagate(param_self=None):

View File

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

View File

@@ -5,10 +5,9 @@ import mimetypes
import os import os
import socket import socket
import threading import threading
from concurrent.futures import Future, ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, Future
from time import sleep from time import sleep
from typing import Callable, List, Any, Optional from typing import Any, Callable, List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
@@ -16,8 +15,8 @@ import bottle
import mpv import mpv
import pychromecast import pychromecast
from sublime.config import AppConfiguration
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.config import AppConfiguration
from sublime.server.api_objects import Child from sublime.server.api_objects import Child
@@ -31,9 +30,11 @@ class PlayerEvent:
class Player: class Player:
_can_hotswap_source: bool
def __init__( def __init__(
self, self,
on_timepos_change: Callable[[float], None], on_timepos_change: Callable[[Optional[float]], None],
on_track_end: Callable[[], None], on_track_end: Callable[[], None],
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
config: AppConfiguration, config: AppConfiguration,
@@ -45,38 +46,38 @@ class Player:
self._song_loaded = False self._song_loaded = False
@property @property
def playing(self): def playing(self) -> bool:
return self._is_playing() return self._is_playing()
@property @property
def song_loaded(self): def song_loaded(self) -> bool:
return self._song_loaded return self._song_loaded
@property @property
def can_hotswap_source(self): def can_hotswap_source(self) -> bool:
return self._can_hotswap_source return self._can_hotswap_source
@property @property
def volume(self): def volume(self) -> float:
return self._get_volume() return self._get_volume()
@volume.setter @volume.setter
def volume(self, value): def volume(self, value: float):
return self._set_volume(value) self._set_volume(value)
@property @property
def is_muted(self): def is_muted(self) -> bool:
return self._get_is_muted() return self._get_is_muted()
@is_muted.setter @is_muted.setter
def is_muted(self, value): def is_muted(self, value: bool):
return 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, progress, song): def play_media(self, file_or_url: str, progress: float, song: Child):
raise NotImplementedError( raise NotImplementedError(
'play_media must be implemented by implementor of Player') 'play_media must be implemented by implementor of Player')
@@ -92,7 +93,7 @@ class Player:
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): def seek(self, value: float):
raise NotImplementedError( raise NotImplementedError(
'seek must be implemented by implementor of Player') 'seek must be implemented by implementor of Player')
@@ -104,7 +105,7 @@ class Player:
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): 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')
@@ -112,7 +113,7 @@ class Player:
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): 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')
@@ -134,7 +135,7 @@ class MPVPlayer(Player):
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(_name, value): def time_observer(_: Any, 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()
@@ -145,7 +146,7 @@ class MPVPlayer(Player):
with self.progress_value_lock: with self.progress_value_lock:
self.progress_value_count += 1 self.progress_value_count += 1
def _is_playing(self): def _is_playing(self) -> bool:
return not self.mpv.pause return not self.mpv.pause
def reset(self): def reset(self):
@@ -153,7 +154,7 @@ 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, progress, song): def play_media(self, file_or_url: str, progress: float, song: Child):
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
@@ -173,20 +174,20 @@ class MPVPlayer(Player):
def toggle_play(self): def toggle_play(self):
self.mpv.cycle('pause') self.mpv.cycle('pause')
def seek(self, value): def seek(self, value: float):
self.mpv.seek(str(value), 'absolute') self.mpv.seek(str(value), 'absolute')
def _set_volume(self, value): def _get_volume(self) -> float:
return self._volume
def _set_volume(self, value: float):
self._volume = value self._volume = value
self.mpv.volume = self._volume self.mpv.volume = self._volume
def _get_volume(self): def _get_is_muted(self) -> bool:
return self._volume
def _get_is_muted(self):
return self._muted return self._muted
def _set_is_muted(self, value): def _set_is_muted(self, value: bool):
self._muted = value self._muted = value
self.mpv.volume = 0 if value else self._volume self.mpv.volume = 0 if value else self._volume
@@ -202,14 +203,14 @@ class ChromecastPlayer(Player):
class CastStatusListener: class CastStatusListener:
on_new_cast_status: Optional[Callable] = None on_new_cast_status: Optional[Callable] = None
def new_cast_status(self, status): def new_cast_status(self, status: Any):
if self.on_new_cast_status: if self.on_new_cast_status:
self.on_new_cast_status(status) self.on_new_cast_status(status)
class MediaStatusListener: class MediaStatusListener:
on_new_media_status: Optional[Callable] = None on_new_media_status: Optional[Callable] = None
def new_media_status(self, status): def new_media_status(self, status: Any):
if self.on_new_media_status: if self.on_new_media_status:
self.on_new_media_status(status) self.on_new_media_status(status)
@@ -217,18 +218,18 @@ class ChromecastPlayer(Player):
media_status_listener = MediaStatusListener() media_status_listener = MediaStatusListener()
class ServerThread(threading.Thread): class ServerThread(threading.Thread):
def __init__(self, host, port): def __init__(self, host: str, port: int):
super().__init__() super().__init__()
self.daemon = True self.daemon = True
self.host = host self.host = host
self.port = port self.port = port
self.token = None self.token: Optional[str] = None
self.song_id = None self.song_id: Optional[str] = None
self.app = bottle.Bottle() self.app = bottle.Bottle()
@self.app.route('/') @self.app.route('/')
def index(): def index() -> str:
return ''' return '''
<h1>Sublime Music Local Music Server</h1> <h1>Sublime Music Local Music Server</h1>
<p> <p>
@@ -238,7 +239,7 @@ class ChromecastPlayer(Player):
''' '''
@self.app.route('/s/<token>') @self.app.route('/s/<token>')
def stream_song(token): def stream_song(token: str) -> bytes:
if token != self.token: if token != self.token:
raise bottle.HTTPError(status=401, body='Invalid token.') raise bottle.HTTPError(status=401, body='Invalid token.')
@@ -254,7 +255,7 @@ class ChromecastPlayer(Player):
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, token): def set_song_and_token(self, song_id: str, token: str):
self.song_id = song_id self.song_id = song_id
self.token = token self.token = token
@@ -265,7 +266,7 @@ class ChromecastPlayer(Player):
@classmethod @classmethod
def get_chromecasts(cls) -> Future: def get_chromecasts(cls) -> Future:
def do_get_chromecasts(): 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
@@ -280,7 +281,7 @@ class ChromecastPlayer(Player):
return ChromecastPlayer.executor.submit(do_get_chromecasts) return ChromecastPlayer.executor.submit(do_get_chromecasts)
def set_playing_chromecast(self, uuid): 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))
@@ -294,7 +295,7 @@ class ChromecastPlayer(Player):
def __init__( def __init__(
self, self,
on_timepos_change: Callable[[float], None], on_timepos_change: Callable[[Optional[float]], None],
on_track_end: Callable[[], None], on_track_end: Callable[[], None],
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
config: AppConfiguration, config: AppConfiguration,
@@ -335,7 +336,10 @@ class ChromecastPlayer(Player):
'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(self, status): def on_new_cast_status(
self,
status: pychromecast.socket_client.CastStatus,
):
self.on_player_event( self.on_player_event(
PlayerEvent( PlayerEvent(
'volume_change', 'volume_change',
@@ -348,7 +352,10 @@ class ChromecastPlayer(Player):
self.on_player_event(PlayerEvent('play_state_change', False)) self.on_player_event(PlayerEvent('play_state_change', False))
self._song_loaded = False self._song_loaded = False
def on_new_media_status(self, status): def on_new_media_status(
self,
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 (status.idle_reason == 'FINISHED' and status.player_state == 'IDLE'
and self._timepos > 0): and self._timepos > 0):
@@ -382,7 +389,7 @@ class ChromecastPlayer(Player):
def start_time_incrementor(self): def start_time_incrementor(self):
ChromecastPlayer.executor.submit(self.time_incrementor) ChromecastPlayer.executor.submit(self.time_incrementor)
def wait_for_playing(self, callback, url=None): def wait_for_playing(self, callback: Callable, url: str = None):
def do_wait_for_playing(): def do_wait_for_playing():
while True: while True:
sleep(0.1) sleep(0.1)
@@ -397,7 +404,7 @@ class ChromecastPlayer(Player):
ChromecastPlayer.executor.submit(do_wait_for_playing) ChromecastPlayer.executor.submit(do_wait_for_playing)
def _is_playing(self): def _is_playing(self) -> bool:
if not self.chromecast or not self.chromecast.media_controller: if not self.chromecast or not self.chromecast.media_controller:
return False return False
return self.chromecast.media_controller.status.player_is_playing return self.chromecast.media_controller.status.player_is_playing
@@ -455,27 +462,27 @@ 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): def seek(self, value: float):
do_pause = not self.playing do_pause = not self.playing
self.chromecast.media_controller.seek(value) self.chromecast.media_controller.seek(value)
if do_pause: if do_pause:
self.pause() self.pause()
def _set_volume(self, value): def _get_volume(self) -> float:
# Chromecast volume is in the range [0, 1], not [0, 100].
if self.chromecast:
self.chromecast.set_volume(value / 100)
def _get_volume(self, value):
if self.chromecast: if self.chromecast:
return self.chromecast.status.volume_level * 100 return self.chromecast.status.volume_level * 100
else: else:
return 100 return 100
def _get_is_muted(self): def _set_volume(self, value: float):
# Chromecast volume is in the range [0, 1], not [0, 100].
if self.chromecast:
self.chromecast.set_volume(value / 100)
def _get_is_muted(self) -> bool:
return self.chromecast.volume_muted return self.chromecast.volume_muted
def _set_is_muted(self, value): def _set_is_muted(self, value: bool):
self.chromecast.set_volume_muted(value) self.chromecast.set_volume_muted(value)
def shutdown(self): def shutdown(self):

View File

@@ -10,7 +10,7 @@ class APIObject:
this only supports JSON. this only supports JSON.
""" """
@classmethod @classmethod
def from_json(cls, data: Dict): def from_json(cls, data: Dict[str, Any]) -> Any:
""" """
Creates an :class:`APIObject` by deserializing JSON data into a Python Creates an :class:`APIObject` by deserializing JSON data into a Python
object. This calls the :class:`sublime.from_json.from_json` function object. This calls the :class:`sublime.from_json.from_json` function
@@ -21,7 +21,7 @@ class APIObject:
""" """
return _from_json(cls, data) return _from_json(cls, data)
def get(self, field: str, default=None): def get(self, field: str, default: Any = None) -> Any:
""" """
Get the value of ``field`` or ``default``. Get the value of ``field`` or ``default``.
@@ -30,7 +30,7 @@ class APIObject:
""" """
return getattr(self, field, default) return getattr(self, field, default)
def __repr__(self): def __repr__(self) -> str:
if isinstance(self, Enum): if isinstance(self, Enum):
return super().__repr__() return super().__repr__()
if isinstance(self, str): if isinstance(self, str):

View File

@@ -6,8 +6,9 @@ script or run it on a new API version.
""" """
from datetime import datetime from datetime import datetime
from typing import List
from enum import Enum from enum import Enum
from typing import Any, List
from sublime.server.api_object import APIObject from sublime.server.api_object import APIObject
@@ -70,10 +71,10 @@ class Child(APIObject):
originalWidth: int originalWidth: int
originalHeight: int originalHeight: int
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Child.{self.id}') return hash(f'Child.{self.id}')
@@ -97,10 +98,10 @@ class AlbumID3(APIObject):
year: int year: int
genre: str genre: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'AlbumID3.{self.id}') return hash(f'AlbumID3.{self.id}')
@@ -125,10 +126,10 @@ class AlbumWithSongsID3(APIObject):
year: int year: int
genre: str genre: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'AlbumID3.{self.id}') return hash(f'AlbumID3.{self.id}')
@@ -141,10 +142,10 @@ class Artist(APIObject):
userRating: UserRating userRating: UserRating
averageRating: AverageRating averageRating: AverageRating
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Artist.{self.id}') return hash(f'Artist.{self.id}')
@@ -178,10 +179,10 @@ class ArtistID3(APIObject):
albumCount: int albumCount: int
starred: datetime starred: datetime
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'ArtistID3.{self.id}') return hash(f'ArtistID3.{self.id}')
@@ -206,10 +207,10 @@ class ArtistWithAlbumsID3(APIObject):
albumCount: int albumCount: int
starred: datetime starred: datetime
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'ArtistID3.{self.id}') return hash(f'ArtistID3.{self.id}')
@@ -263,10 +264,10 @@ class Directory(APIObject):
averageRating: AverageRating averageRating: AverageRating
playCount: int playCount: int
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Directory.{self.id}') return hash(f'Directory.{self.id}')
@@ -309,10 +310,10 @@ class InternetRadioStation(APIObject):
streamUrl: str streamUrl: str
homePageUrl: str homePageUrl: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'InternetRadioStation.{self.id}') return hash(f'InternetRadioStation.{self.id}')
@@ -357,10 +358,10 @@ class MusicFolder(APIObject):
value: str value: str
name: str name: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'MusicFolder.{self.id}') return hash(f'MusicFolder.{self.id}')
@@ -417,10 +418,10 @@ class PodcastEpisode(APIObject):
originalWidth: int originalWidth: int
originalHeight: int originalHeight: int
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Child.{self.id}') return hash(f'Child.{self.id}')
@@ -467,10 +468,10 @@ class NowPlayingEntry(APIObject):
originalWidth: int originalWidth: int
originalHeight: int originalHeight: int
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Child.{self.id}') return hash(f'Child.{self.id}')
@@ -503,10 +504,10 @@ class Playlist(APIObject):
changed: datetime changed: datetime
coverArt: str coverArt: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Playlist.{self.id}') return hash(f'Playlist.{self.id}')
@@ -525,10 +526,10 @@ class PlaylistWithSongs(APIObject):
changed: datetime changed: datetime
coverArt: str coverArt: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Playlist.{self.id}') return hash(f'Playlist.{self.id}')
@@ -549,10 +550,10 @@ class PodcastChannel(APIObject):
status: PodcastStatus status: PodcastStatus
errorMessage: str errorMessage: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'PodcastChannel.{self.id}') return hash(f'PodcastChannel.{self.id}')
@@ -605,10 +606,10 @@ class Share(APIObject):
lastVisited: datetime lastVisited: datetime
visitCount: int visitCount: int
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Share.{self.id}') return hash(f'Share.{self.id}')
@@ -688,10 +689,10 @@ class AudioTrack(APIObject):
name: str name: str
languageCode: str languageCode: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'AudioTrack.{self.id}') return hash(f'AudioTrack.{self.id}')
@@ -700,10 +701,10 @@ class Captions(APIObject):
value: str value: str
name: str name: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'Captions.{self.id}') return hash(f'Captions.{self.id}')
@@ -713,10 +714,10 @@ class VideoConversion(APIObject):
bitRate: int bitRate: int
audioTrackId: int audioTrackId: int
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'VideoConversion.{self.id}') return hash(f'VideoConversion.{self.id}')
@@ -727,10 +728,10 @@ class VideoInfo(APIObject):
value: str value: str
id: str id: str
def __eq__(self, other): def __eq__(self, other: Any) -> bool:
return hash(self) == hash(other) return hash(self) == hash(other)
def __hash__(self): def __hash__(self) -> int:
return hash(f'VideoInfo.{self.id}') return hash(f'VideoInfo.{self.id}')

View File

@@ -1,14 +1,13 @@
import logging import logging
import math import math
import os import os
from time import sleep
from urllib.parse import urlencode
from deprecated import deprecated
from typing import Optional, Dict, List, Union, cast
from datetime import datetime from datetime import datetime
from time import sleep
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urlencode
import requests import requests
from deprecated import deprecated
from .api_objects import ( from .api_objects import (
AlbumInfo, AlbumInfo,
@@ -22,6 +21,7 @@ from .api_objects import (
Bookmarks, Bookmarks,
Child, Child,
Directory, Directory,
Error,
Genres, Genres,
Indexes, Indexes,
InternetRadioStations, InternetRadioStations,
@@ -38,9 +38,9 @@ from .api_objects import (
SearchResult2, SearchResult2,
SearchResult3, SearchResult3,
Shares, Shares,
Songs,
Starred, Starred,
Starred2, Starred2,
Songs,
User, User,
Users, Users,
VideoInfo, VideoInfo,
@@ -61,6 +61,10 @@ class Server:
* The ``server`` module is stateless. The only thing that it does is allow * The ``server`` module is stateless. The only thing that it does is allow
the module's user to query the \\*sonic server via the API. the module's user to query the \\*sonic server via the API.
""" """
class SubsonicServerError(Exception):
def __init__(self: 'Server.SubsonicServerError', error: Error):
super().__init__(f'{error.code}: {error.message}')
def __init__( def __init__(
self, self,
name: str, name: str,
@@ -77,22 +81,19 @@ class Server:
def _get_params(self) -> Dict[str, str]: def _get_params(self) -> Dict[str, str]:
"""See Subsonic API Introduction for details.""" """See Subsonic API Introduction for details."""
return dict( return {
u=self.username, 'u': self.username,
p=self.password, 'p': self.password,
c='Sublime Music', 'c': 'Sublime Music',
f='json', 'f': 'json',
v='1.15.0', 'v': '1.15.0',
) }
def _make_url(self, endpoint: str) -> str: def _make_url(self, endpoint: str) -> str:
return f'{self.hostname}/rest/{endpoint}.view' return f'{self.hostname}/rest/{endpoint}.view'
def _subsonic_error_to_exception(self, error) -> Exception:
return Exception(f'{error.code}: {error.message}')
# def _get(self, url, timeout=(3.05, 2), **params): # def _get(self, url, timeout=(3.05, 2), **params):
def _get(self, url, **params): def _get(self, url: str, **params) -> Any:
params = {**self._get_params(), **params} params = {**self._get_params(), **params}
logging.info(f'[START] get: {url}') logging.info(f'[START] get: {url}')
@@ -105,7 +106,7 @@ class Server:
# Deal with datetime parameters (convert to milliseconds since 1970) # Deal with datetime parameters (convert to milliseconds since 1970)
for k, v in params.items(): for k, v in params.items():
if type(v) == datetime: if type(v) == datetime:
params[k] = int(cast(datetime, v).timestamp() * 1000) params[k] = int(v.timestamp() * 1000)
result = requests.get( result = requests.get(
url, url,
@@ -151,11 +152,11 @@ class Server:
# Check for an error and if it exists, raise it. # Check for an error and if it exists, raise it.
if response.get('error'): if response.get('error'):
raise self._subsonic_error_to_exception(response.error) raise Server.SubsonicServerError(response.error)
return response return response
def do_download(self, url, **params) -> bytes: def do_download(self, url: str, **params) -> bytes:
download = self._get(url, **params) download = self._get(url, **params)
if 'json' in download.headers.get('Content-Type'): if 'json' in download.headers.get('Content-Type'):
# TODO (#122): make better # TODO (#122): make better
@@ -201,7 +202,7 @@ class Server:
ifModifiedSince=if_modified_since) ifModifiedSince=if_modified_since)
return result.indexes return result.indexes
def get_music_directory(self, dir_id) -> Directory: def get_music_directory(self, dir_id: Union[int, str]) -> Directory:
""" """
Returns a listing of all files in a music directory. Typically used Returns a listing of all files in a music directory. Typically used
to get list of albums for an artist, or list of songs for an album. to get list of albums for an artist, or list of songs for an album.
@@ -779,17 +780,17 @@ class Server:
return self._get_json(self._make_url('deletePlaylist'), id=id) return self._get_json(self._make_url('deletePlaylist'), id=id)
def get_stream_url( def get_stream_url(
self, self,
id: str, id: str,
max_bit_rate: int = None, max_bit_rate: int = None,
format: str = None, format: str = None,
time_offset: int = None, time_offset: int = None,
size: int = None, size: int = None,
estimate_content_length: bool = False, estimate_content_length: bool = False,
converted: bool = False, converted: bool = False,
): ) -> str:
""" """
Gets the URL to streams a given file. Gets the URL to stream a given file.
:param id: A string which uniquely identifies the file to stream. :param id: A string which uniquely identifies the file to stream.
Obtained by calls to ``getMusicDirectory``. Obtained by calls to ``getMusicDirectory``.
@@ -829,7 +830,7 @@ class Server:
params = {k: v for k, v in params.items() if v} params = {k: v for k, v in params.items() if v}
return self._make_url('stream') + '?' + urlencode(params) return self._make_url('stream') + '?' + urlencode(params)
def download(self, id: str): def download(self, id: str) -> bytes:
""" """
Downloads a given media file. Similar to stream, but this method Downloads a given media file. Similar to stream, but this method
returns the original media data without transcoding or downsampling. returns the original media data without transcoding or downsampling.
@@ -839,7 +840,7 @@ class Server:
""" """
return self.do_download(self._make_url('download'), id=id) return self.do_download(self._make_url('download'), id=id)
def get_cover_art(self, id: str, size: str = None): def get_cover_art(self, id: str, size: str = None) -> bytes:
""" """
Returns the cover art image in binary form. Returns the cover art image in binary form.
@@ -849,7 +850,7 @@ class Server:
return self.do_download( return self.do_download(
self._make_url('getCoverArt'), id=id, size=size) self._make_url('getCoverArt'), id=id, size=size)
def get_cover_art_url(self, id: str, size: str = None): def get_cover_art_url(self, id: str, size: str = None) -> str:
""" """
Returns the cover art image in binary form. Returns the cover art image in binary form.
@@ -874,7 +875,7 @@ class Server:
) )
return result.lyrics return result.lyrics
def get_avatar(self, username: str): def get_avatar(self, username: str) -> bytes:
""" """
Returns the avatar (personal image) for a user. Returns the avatar (personal image) for a user.

View File

@@ -1,17 +1,16 @@
import os
import json import json
import os
from enum import Enum from enum import Enum
from typing import List, Optional, Set from typing import Any, Dict, List, Optional, Set
import gi import gi
gi.require_version('NetworkManager', '1.0') gi.require_version('NetworkManager', '1.0')
gi.require_version('NMClient', '1.0') gi.require_version('NMClient', '1.0')
from gi.repository import NetworkManager, NMClient from gi.repository import NetworkManager, NMClient
from .from_json import from_json
from .config import AppConfiguration
from .cache_manager import CacheManager from .cache_manager import CacheManager
from .config import AppConfiguration
from .from_json import from_json
from .server.api_objects import Child from .server.api_objects import Child
@@ -21,19 +20,19 @@ class RepeatType(Enum):
REPEAT_SONG = 2 REPEAT_SONG = 2
@property @property
def icon(self): def icon(self) -> str:
icon_name = [ icon_name = [
'repeat', 'repeat',
'repeat-symbolic', 'repeat-symbolic',
'repeat-song-symbolic', 'repeat-song-symbolic',
][self.value] ][self.value]
return 'media-playlist-' + icon_name return f'media-playlist-{icon_name}'
def as_mpris_loop_status(self): def as_mpris_loop_status(self) -> str:
return ['None', 'Playlist', 'Track'][self.value] return ['None', 'Playlist', 'Track'][self.value]
@staticmethod @staticmethod
def from_mpris_loop_status(loop_status): def from_mpris_loop_status(loop_status: str) -> 'RepeatType':
return { return {
'None': RepeatType.NO_REPEAT, 'None': RepeatType.NO_REPEAT,
'Track': RepeatType.REPEAT_SONG, 'Track': RepeatType.REPEAT_SONG,
@@ -62,7 +61,7 @@ class ApplicationState:
current_song_index: int = -1 current_song_index: int = -1
play_queue: List[str] = [] play_queue: List[str] = []
old_play_queue: List[str] = [] old_play_queue: List[str] = []
_volume: dict = {'this device': 100} _volume: Dict[str, float] = {'this device': 100.0}
is_muted: bool = False is_muted: bool = False
repeat_type: RepeatType = RepeatType.NO_REPEAT repeat_type: RepeatType = RepeatType.NO_REPEAT
shuffle_on: bool = False shuffle_on: bool = False
@@ -87,7 +86,7 @@ class ApplicationState:
nmclient_initialized = False nmclient_initialized = False
_current_ssids: Set[str] = set() _current_ssids: Set[str] = set()
def to_json(self): def to_json(self) -> Dict[str, Any]:
exclude = ('config', 'repeat_type', '_current_ssids') exclude = ('config', 'repeat_type', '_current_ssids')
json_object = { json_object = {
k: getattr(self, k) k: getattr(self, k)
@@ -101,12 +100,12 @@ class ApplicationState:
}) })
return json_object return json_object
def load_from_json(self, json_object): def load_from_json(self, json_object: Dict[str, Any]):
self.version = json_object.get('version', 0) self.version = json_object.get('version', 0)
self.current_song_index = json_object.get('current_song_index', -1) self.current_song_index = json_object.get('current_song_index', -1)
self.play_queue = json_object.get('play_queue', []) self.play_queue = json_object.get('play_queue', [])
self.old_play_queue = json_object.get('old_play_queue', []) self.old_play_queue = json_object.get('old_play_queue', [])
self._volume = json_object.get('_volume', {'this device': 100}) self._volume = json_object.get('_volume', {'this device': 100.0})
self.is_muted = json_object.get('is_muted', False) self.is_muted = json_object.get('is_muted', False)
self.repeat_type = RepeatType(json_object.get('repeat_type', 0)) self.repeat_type = RepeatType(json_object.get('repeat_type', 0))
self.shuffle_on = json_object.get('shuffle_on', False) self.shuffle_on = json_object.get('shuffle_on', False)
@@ -183,7 +182,7 @@ class ApplicationState:
return AppConfiguration() return AppConfiguration()
@property @property
def current_ssids(self): def current_ssids(self) -> Set[str]:
if not self.nmclient_initialized: if not self.nmclient_initialized:
# Only look at the active WiFi connections. # Only look at the active WiFi connections.
for ac in self.networkmanager_client.get_active_connections(): for ac in self.networkmanager_client.get_active_connections():
@@ -200,7 +199,7 @@ class ApplicationState:
return self._current_ssids return self._current_ssids
@property @property
def state_filename(self): def state_filename(self) -> str:
default_cache_location = ( default_cache_location = (
os.environ.get('XDG_DATA_HOME') os.environ.get('XDG_DATA_HOME')
or os.path.expanduser('~/.local/share')) or os.path.expanduser('~/.local/share'))
@@ -221,9 +220,9 @@ class ApplicationState:
return CacheManager.get_song_details(current_song_id).result() return CacheManager.get_song_details(current_song_id).result()
@property @property
def volume(self): def volume(self) -> float:
return self._volume.get(self.current_device, 100) return self._volume.get(self.current_device, 100.0)
@volume.setter @volume.setter
def volume(self, value): def volume(self, value: float):
self._volume[self.current_device] = value self._volume[self.current_device] = value

View File

@@ -1,18 +1,16 @@
import logging import logging
from typing import Any, Callable, Iterable, Optional, Tuple, Union
import gi import gi
from typing import Union
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject, GLib, Gio, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.state_manager import ApplicationState
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.server.api_objects import AlbumWithSongsID3, 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
from sublime.server.api_objects import Child, AlbumWithSongsID3
Album = Union[Child, AlbumWithSongsID3] Album = Union[Child, AlbumWithSongsID3]
@@ -97,7 +95,11 @@ class AlbumsPanel(Gtk.Box):
scrolled_window.add(self.grid) scrolled_window.add(self.grid)
self.add(scrolled_window) self.add(scrolled_window)
def make_combobox(self, items, on_change): def make_combobox(
self,
items: Iterable[Tuple[str, str]],
on_change: Callable[['AlbumsPanel', Gtk.ComboBox], None],
) -> Gtk.ComboBox:
store = Gtk.ListStore(str, str) store = Gtk.ListStore(str, str)
for item in items: for item in items:
store.append(item) store.append(item)
@@ -120,7 +122,7 @@ class AlbumsPanel(Gtk.Box):
if not CacheManager.ready(): if not CacheManager.ready():
return return
def get_genres_done(f): def get_genres_done(f: CacheManager.Result):
try: try:
new_store = [ new_store = [
(genre.value, genre.value) for genre in (f.result() or []) (genre.value, genre.value) for genre in (f.result() or [])
@@ -150,7 +152,7 @@ class AlbumsPanel(Gtk.Box):
self.populate_genre_combo(state, force=force) self.populate_genre_combo(state, force=force)
# Show/hide the combo boxes. # Show/hide the combo boxes.
def show_if(sort_type, *elements): def show_if(sort_type: str, *elements):
for element in elements: for element in elements:
if state.current_album_sort == sort_type: if state.current_album_sort == sort_type:
element.show() element.show()
@@ -175,15 +177,16 @@ class AlbumsPanel(Gtk.Box):
selected_id=state.selected_album_id, selected_id=state.selected_album_id,
) )
def get_id(self, combo): def get_id(self, combo: Gtk.ComboBox) -> Optional[int]:
tree_iter = combo.get_active_iter() tree_iter = combo.get_active_iter()
if tree_iter is not None: if tree_iter is not None:
return combo.get_model()[tree_iter][0] return combo.get_model()[tree_iter][0]
return None
def on_refresh_clicked(self, button): def on_refresh_clicked(self, button: Any):
self.emit('refresh-window', {}, True) self.emit('refresh-window', {}, True)
def on_type_combo_changed(self, combo): def on_type_combo_changed(self, combo: Gtk.ComboBox):
new_active_sort = self.get_id(combo) new_active_sort = self.get_id(combo)
self.grid.update_params(type_=new_active_sort) self.grid.update_params(type_=new_active_sort)
self.emit_if_not_updating( self.emit_if_not_updating(
@@ -195,7 +198,7 @@ class AlbumsPanel(Gtk.Box):
False, False,
) )
def on_alphabetical_type_change(self, combo): def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
new_active_alphabetical_sort = self.get_id(combo) new_active_alphabetical_sort = self.get_id(combo)
self.grid.update_params(alphabetical_type=new_active_alphabetical_sort) self.grid.update_params(alphabetical_type=new_active_alphabetical_sort)
self.emit_if_not_updating( self.emit_if_not_updating(
@@ -208,7 +211,7 @@ class AlbumsPanel(Gtk.Box):
False, False,
) )
def on_genre_change(self, combo): def on_genre_change(self, combo: Gtk.ComboBox):
new_active_genre = self.get_id(combo) new_active_genre = self.get_id(combo)
self.grid.update_params(genre=new_active_genre) self.grid.update_params(genre=new_active_genre)
self.emit_if_not_updating( self.emit_if_not_updating(
@@ -220,7 +223,7 @@ class AlbumsPanel(Gtk.Box):
True, True,
) )
def on_year_changed(self, entry): def on_year_changed(self, entry: Gtk.Entry):
try: try:
year = int(entry.get_text()) year = int(entry.get_text())
except Exception: except Exception:
@@ -250,7 +253,7 @@ class AlbumsPanel(Gtk.Box):
True, True,
) )
def on_grid_cover_clicked(self, grid, id): def on_grid_cover_clicked(self, grid: Any, id: str):
self.emit( self.emit(
'refresh-window', 'refresh-window',
{'selected_album_id': id}, {'selected_album_id': id},
@@ -263,19 +266,6 @@ class AlbumsPanel(Gtk.Box):
self.emit(*args) self.emit(*args)
class AlbumModel(GObject.Object):
def __init__(self, album: Album):
self.album: Album = album
super().__init__()
@property
def id(self):
return self.album.id
def __repr__(self):
return f'<AlbumModel {self.album}>'
class AlbumsGrid(Gtk.Overlay): class AlbumsGrid(Gtk.Overlay):
"""Defines the albums panel.""" """Defines the albums panel."""
__gsignals__ = { __gsignals__ = {
@@ -303,6 +293,18 @@ class AlbumsGrid(Gtk.Overlay):
overshoot_update_in_progress = False overshoot_update_in_progress = False
server_hash = None server_hash = None
class AlbumModel(GObject.Object):
def __init__(self, album: Album):
self.album: Album = album
super().__init__()
@property
def id(self) -> str:
return self.album.id
def __repr__(self) -> str:
return f'<AlbumsGrid.AlbumModel {self.album}>'
def update_params( def update_params(
self, self,
type_: str = None, type_: str = None,
@@ -408,12 +410,12 @@ class AlbumsGrid(Gtk.Overlay):
error_dialog = None error_dialog = None
def update_grid(self, force=False, selected_id=None): def update_grid(self, force: bool = False, selected_id: str = None):
if not CacheManager.ready(): if not CacheManager.ready():
self.spinner.hide() self.spinner.hide()
return return
def reflow_grid(force_reload, selected_index): def reflow_grid(force_reload: bool, selected_index: Optional[int]):
selection_changed = (selected_index != self.current_selection) selection_changed = (selected_index != self.current_selection)
self.current_selection = selected_index self.current_selection = selected_index
self.reflow_grids( self.reflow_grids(
@@ -434,7 +436,7 @@ class AlbumsGrid(Gtk.Overlay):
require_reflow = self.parameters_changed require_reflow = self.parameters_changed
self.parameters_changed = False self.parameters_changed = False
def do_update(f): def do_update(f: CacheManager.Result):
try: try:
albums = f.result() albums = f.result()
except Exception as e: except Exception as e:
@@ -461,7 +463,7 @@ class AlbumsGrid(Gtk.Overlay):
selected_index = None selected_index = None
for i, album in enumerate(albums): for i, album in enumerate(albums):
model = AlbumModel(album) model = AlbumsGrid.AlbumModel(album)
if model.id == selected_id: if model.id == selected_id:
selected_index = i selected_index = i
@@ -485,7 +487,7 @@ class AlbumsGrid(Gtk.Overlay):
# Event Handlers # Event Handlers
# ========================================================================= # =========================================================================
def on_child_activated(self, flowbox, child): def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget):
click_top = flowbox == self.grid_top click_top = flowbox == self.grid_top
selected_index = ( selected_index = (
child.get_index() + (0 if click_top else len(self.list_store_top))) child.get_index() + (0 if click_top else len(self.list_store_top)))
@@ -495,7 +497,7 @@ class AlbumsGrid(Gtk.Overlay):
else: else:
self.emit('cover-clicked', self.list_store[selected_index].id) self.emit('cover-clicked', self.list_store[selected_index].id)
def on_grid_resize(self, flowbox, rect): def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle):
# TODO (#124): this doesn't work with themes that add extra padding. # TODO (#124): this doesn't work with themes that add extra padding.
# 200 + (10 * 2) + (5 * 2) = 230 # 200 + (10 * 2) + (5 * 2) = 230
# picture + (padding * 2) + (margin * 2) # picture + (padding * 2) + (margin * 2)
@@ -511,7 +513,7 @@ class AlbumsGrid(Gtk.Overlay):
# Helper Methods # Helper Methods
# ========================================================================= # =========================================================================
def create_widget(self, item): def create_widget(self, item: 'AlbumsGrid.AlbumModel') -> Gtk.Box:
widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Cover art image # Cover art image
@@ -523,7 +525,7 @@ class AlbumsGrid(Gtk.Overlay):
) )
widget_box.pack_start(artwork, False, False, 0) widget_box.pack_start(artwork, False, False, 0)
def make_label(text, name): def make_label(text: str, name: str) -> Gtk.Label:
return Gtk.Label( return Gtk.Label(
name=name, name=name,
label=text, label=text,
@@ -535,7 +537,8 @@ class AlbumsGrid(Gtk.Overlay):
# Header for the widget # Header for the widget
header_text = ( header_text = (
item.album.title if type(item.album) == Child else item.album.name) item.album.title
if isinstance(item.album, Child) else item.album.name)
header_label = make_label(header_text, 'grid-header-label') header_label = make_label(header_text, 'grid-header-label')
widget_box.pack_start(header_label, False, False, 0) widget_box.pack_start(header_label, False, False, 0)
@@ -547,7 +550,7 @@ class AlbumsGrid(Gtk.Overlay):
widget_box.pack_start(info_label, False, False, 0) widget_box.pack_start(info_label, False, False, 0)
# Download the cover art. # Download the cover art.
def on_artwork_downloaded(f): def on_artwork_downloaded(f: CacheManager.Result):
artwork.set_from_file(f.result()) artwork.set_from_file(f.result())
artwork.set_loading(False) artwork.set_loading(False)
@@ -566,8 +569,8 @@ class AlbumsGrid(Gtk.Overlay):
def reflow_grids( def reflow_grids(
self, self,
force_reload_from_master=False, force_reload_from_master: bool = False,
selection_changed=False, selection_changed: bool = False,
): ):
# Determine where the cuttoff is between the top and bottom grids. # Determine where the cuttoff is between the top and bottom grids.
entries_before_fold = len(self.list_store) entries_before_fold = len(self.list_store)

View File

@@ -1,22 +1,21 @@
from typing import cast, List, Union
from random import randint from random import randint
from typing import Any, cast, List, Optional, Union
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject, Pango, GLib, Gio from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.state_manager import ApplicationState
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.ui import util
from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage
from sublime.server.api_objects import ( from sublime.server.api_objects import (
AlbumID3, AlbumID3,
ArtistID3,
ArtistInfo2, ArtistInfo2,
ArtistWithAlbumsID3, ArtistWithAlbumsID3,
Child, Child,
) )
from sublime.state_manager import ApplicationState
from sublime.ui import util
from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage
class ArtistsPanel(Gtk.Paned): class ArtistsPanel(Gtk.Paned):
@@ -48,23 +47,24 @@ class ArtistsPanel(Gtk.Paned):
) )
self.pack2(self.artist_detail_panel, True, False) self.pack2(self.artist_detail_panel, True, False)
def update(self, state: ApplicationState, force=False): def update(self, state: ApplicationState, force: bool = False):
self.artist_list.update(state=state) self.artist_list.update(state=state)
self.artist_detail_panel.update(state=state) self.artist_detail_panel.update(state=state)
class _ArtistModel(GObject.GObject):
artist_id = GObject.Property(type=str)
name = GObject.Property(type=str)
album_count = GObject.Property(type=int)
def __init__(self, artist_id: str, name: str, album_count: int):
GObject.GObject.__init__(self)
self.artist_id = artist_id
self.name = name
self.album_count = album_count
class ArtistList(Gtk.Box): class ArtistList(Gtk.Box):
class ArtistModel(GObject.GObject):
artist_id = GObject.Property(type=str)
name = GObject.Property(type=str)
album_count = GObject.Property(type=int)
def __init__(self, artist_id, name, album_count):
GObject.GObject.__init__(self)
self.artist_id = artist_id
self.name = name
self.album_count = album_count
def __init__(self): def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
@@ -85,7 +85,7 @@ 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: ArtistList.ArtistModel): 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
@@ -122,7 +122,12 @@ class ArtistList(Gtk.Box):
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(self, artists, state: ApplicationState, **kwargs): def update(
self,
artists: List[ArtistID3],
state: ApplicationState,
**kwargs,
):
new_store = [] new_store = []
selected_idx = None selected_idx = None
for i, artist in enumerate(artists): for i, artist in enumerate(artists):
@@ -130,7 +135,7 @@ class ArtistList(Gtk.Box):
selected_idx = i selected_idx = i
new_store.append( new_store.append(
ArtistList.ArtistModel( _ArtistModel(
artist.id, artist.id,
artist.name, artist.name,
artist.get('albumCount', ''), artist.get('albumCount', ''),
@@ -299,8 +304,8 @@ class ArtistDetailPanel(Gtk.Box):
self, self,
artist: ArtistWithAlbumsID3, artist: ArtistWithAlbumsID3,
state: ApplicationState, state: ApplicationState,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return return
@@ -330,8 +335,8 @@ class ArtistDetailPanel(Gtk.Box):
self, self,
artist_info: ArtistInfo2, artist_info: ArtistInfo2,
state: ApplicationState, state: ApplicationState,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return return
@@ -363,10 +368,10 @@ class ArtistDetailPanel(Gtk.Box):
) )
def update_artist_artwork( def update_artist_artwork(
self, self,
cover_art_filename, cover_art_filename: str,
state: ApplicationState, state: ApplicationState,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
if order_token != self.update_order_token: if order_token != self.update_order_token:
return return
@@ -383,9 +388,9 @@ class ArtistDetailPanel(Gtk.Box):
order_token=self.update_order_token, order_token=self.update_order_token,
) )
def on_download_all_click(self, btn): def on_download_all_click(self, btn: Any):
CacheManager.batch_download_songs( CacheManager.batch_download_songs(
self.get_artist_songs(), 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,
@@ -396,8 +401,8 @@ class ArtistDetailPanel(Gtk.Box):
), ),
) )
def on_play_all_clicked(self, btn): def on_play_all_clicked(self, btn: Any):
songs = self.get_artist_songs() songs = self.get_artist_song_ids()
self.emit( self.emit(
'song-clicked', 'song-clicked',
0, 0,
@@ -405,8 +410,8 @@ class ArtistDetailPanel(Gtk.Box):
{'force_shuffle_state': False}, {'force_shuffle_state': False},
) )
def on_shuffle_all_button(self, btn): def on_shuffle_all_button(self, btn: Any):
songs = self.get_artist_songs() songs = self.get_artist_song_ids()
self.emit( self.emit(
'song-clicked', 'song-clicked',
randint(0, randint(0,
@@ -417,7 +422,7 @@ class ArtistDetailPanel(Gtk.Box):
# Helper Methods # Helper Methods
# ========================================================================= # =========================================================================
def set_all_loading(self, loading_state): def set_all_loading(self, loading_state: bool):
if loading_state: if loading_state:
self.albums_list.spinner.start() self.albums_list.spinner.start()
self.albums_list.spinner.show() self.albums_list.spinner.show()
@@ -426,7 +431,12 @@ class ArtistDetailPanel(Gtk.Box):
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(self, text=None, name=None, **params): def make_label(
self,
text: str = None,
name: str = None,
**params,
) -> Gtk.Label:
return Gtk.Label( return Gtk.Label(
label=text, label=text,
name=name, name=name,
@@ -435,34 +445,21 @@ class ArtistDetailPanel(Gtk.Box):
**params, **params,
) )
def format_stats(self, artist): def format_stats(self, artist: ArtistWithAlbumsID3) -> str:
album_count = artist.get('albumCount', len(artist.get('child') or [])) album_count = artist.get('albumCount', 0)
components = [ song_count = sum(a.songCount for a in artist.album)
duration = sum(a.duration for a in artist.album)
return util.dot_join(
'{} {}'.format(album_count, util.pluralize('album', album_count)), '{} {}'.format(album_count, util.pluralize('album', album_count)),
] '{} {}'.format(song_count, util.pluralize('song', song_count)),
util.format_sequence_duration(duration),
)
if artist.get('album'): def get_artist_song_ids(self) -> List[int]:
song_count = sum(a.songCount for a in artist.album)
duration = sum(a.duration for a in artist.album)
components += [
'{} {}'.format(song_count, util.pluralize('song', song_count)),
util.format_sequence_duration(duration),
]
elif artist.get('child'):
plays = sum(c.playCount for c in artist.child)
components += [
'{} {}'.format(plays, util.pluralize('play', plays)),
]
return util.dot_join(*components)
def get_artist_songs(self):
songs = [] songs = []
artist = CacheManager.get_artist(self.artist_id).result() for album in CacheManager.get_artist(self.artist_id).result().album:
for album in artist.get('album', artist.get('child', [])):
album_songs = CacheManager.get_album(album.id).result() album_songs = CacheManager.get_album(album.id).result()
album_songs = album_songs.get('child', album_songs.get('song', [])) for song in album_songs.get('song', []):
for song in album_songs:
songs.append(song.id) songs.append(song.id)
return songs return songs
@@ -494,7 +491,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.albums = [] self.albums = []
def update(self, artist): def update(self, artist: ArtistWithAlbumsID3):
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)
@@ -528,7 +525,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.spinner.stop() self.spinner.stop()
self.spinner.hide() self.spinner.hide()
def on_song_selected(self, album_component): def on_song_selected(self, album_component: AlbumWithSongs):
for child in self.box.get_children(): for child in self.box.get_children():
if album_component != child: if album_component != child:
child.deselect_all() child.deselect_all()

View File

@@ -1,17 +1,15 @@
from typing import List, Tuple, Union from typing import List, Tuple, Union
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject, Pango, GLib, Gio from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.state_manager import ApplicationState
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.server.api_objects import Artist, Child
from sublime.state_manager import ApplicationState
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import IconButton from sublime.ui.common import IconButton
from sublime.server.api_objects import Child, Artist
class BrowsePanel(Gtk.Overlay): class BrowsePanel(Gtk.Overlay):
"""Defines the arist panel.""" """Defines the arist panel."""

View File

@@ -1,22 +1,16 @@
from typing import Union
from random import randint from random import randint
from typing import Any, Optional, Union
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject, Pango, GLib from gi.repository import Gdk, GLib, GObject, Gtk, Pango
from sublime.state_manager import ApplicationState
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.server.api_objects import AlbumWithSongsID3, Child, Directory
from sublime.state_manager import ApplicationState
from sublime.ui import util from sublime.ui import util
from .icon_button import IconButton from sublime.ui.common.icon_button import IconButton
from .spinner_image import SpinnerImage from sublime.ui.common.spinner_image import SpinnerImage
from sublime.server.api_objects import (
AlbumWithSongsID3,
Child,
Directory,
)
class AlbumWithSongs(Gtk.Box): class AlbumWithSongs(Gtk.Box):
@@ -33,7 +27,12 @@ class AlbumWithSongs(Gtk.Box):
), ),
} }
def __init__(self, album, cover_art_size=200, show_artist_name=True): def __init__(
self,
album: AlbumWithSongsID3,
cover_art_size: int = 200,
show_artist_name: bool = True,
):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
self.album = album self.album = album
@@ -51,7 +50,7 @@ class AlbumWithSongs(Gtk.Box):
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): def cover_art_future_done(f: CacheManager.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)
@@ -125,7 +124,13 @@ class AlbumWithSongs(Gtk.Box):
str, # song ID str, # song ID
) )
def create_column(header, text_idx, bold=False, align=0, width=None): def create_column(
header: str,
text_idx: int,
bold: bool = False,
align: int = 0,
width: Optional[int] = None,
) -> Gtk.TreeViewColumn:
renderer = Gtk.CellRendererText( renderer = Gtk.CellRendererText(
xalign=align, xalign=align,
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL, weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
@@ -177,11 +182,11 @@ class AlbumWithSongs(Gtk.Box):
# Event Handlers # Event Handlers
# ========================================================================= # =========================================================================
def on_song_selection_change(self, event): 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, idx, column): 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',
@@ -190,7 +195,7 @@ class AlbumWithSongs(Gtk.Box):
{}, {},
) )
def on_song_button_press(self, tree, event): def on_song_button_press(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)
if not clicked_path: if not clicked_path:
@@ -199,7 +204,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(song_id=None): def on_download_state_change(song_id: Any = None):
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
@@ -228,14 +233,16 @@ class AlbumWithSongs(Gtk.Box):
if not allow_deselect: if not allow_deselect:
return True return True
def on_download_all_click(self, btn): return False
def on_download_all_click(self, btn: Any):
CacheManager.batch_download_songs( CacheManager.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=self.update,
on_song_download_complete=lambda x: self.update(), on_song_download_complete=lambda x: self.update(),
) )
def play_btn_clicked(self, btn): 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',
@@ -244,7 +251,7 @@ class AlbumWithSongs(Gtk.Box):
{'force_shuffle_state': False}, {'force_shuffle_state': False},
) )
def shuffle_btn_clicked(self, btn): 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',
@@ -259,10 +266,10 @@ class AlbumWithSongs(Gtk.Box):
def deselect_all(self): def deselect_all(self):
self.album_songs.get_selection().unselect_all() self.album_songs.get_selection().unselect_all()
def update(self, force=False): def update(self, force: bool = False):
self.update_album_songs(self.album.id) self.update_album_songs(self.album.id)
def set_loading(self, loading): def set_loading(self, loading: bool):
if loading: if loading:
self.loading_indicator.start() self.loading_indicator.start()
self.loading_indicator.show() self.loading_indicator.show()
@@ -279,8 +286,8 @@ class AlbumWithSongs(Gtk.Box):
self, self,
album: Union[AlbumWithSongsID3, Child, Directory], album: Union[AlbumWithSongsID3, Child, Directory],
state: ApplicationState, state: ApplicationState,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
new_store = [ new_store = [
[ [

View File

@@ -1,4 +1,4 @@
from typing import List, Tuple, Optional from typing import Any, List, Optional, Tuple
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
@@ -17,7 +17,7 @@ class EditFormDialog(Gtk.Dialog):
extra_label: Optional[str] = None extra_label: Optional[str] = None
extra_buttons: List[Gtk.Button] = [] extra_buttons: List[Gtk.Button] = []
def get_object_name(self, obj): def get_object_name(self, obj: Any) -> str:
""" """
Gets the friendly object name. Can be overridden. Gets the friendly object name. Can be overridden.
""" """
@@ -26,7 +26,7 @@ class EditFormDialog(Gtk.Dialog):
def get_default_object(self): def get_default_object(self):
return None return None
def __init__(self, parent, existing_object=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', lambda: None) title = getattr(self, 'title', lambda: None)
if not title: if not title:

View File

@@ -1,3 +1,5 @@
from typing import Optional
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk from gi.repository import Gtk
@@ -5,12 +7,12 @@ from gi.repository import Gtk
class IconButton(Gtk.Button): class IconButton(Gtk.Button):
def __init__( def __init__(
self, self,
icon_name, icon_name: Optional[str],
relief=False, relief: bool = False,
icon_size=Gtk.IconSize.BUTTON, icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
label=None, label: str = None,
**kwargs, **kwargs,
): ):
Gtk.Button.__init__(self, **kwargs) Gtk.Button.__init__(self, **kwargs)
self.icon_size = icon_size self.icon_size = icon_size
@@ -29,5 +31,5 @@ class IconButton(Gtk.Button):
self.add(box) self.add(box)
def set_icon(self, icon_name): def set_icon(self, icon_name: str):
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,16 +1,18 @@
from typing import Optional
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GdkPixbuf from gi.repository import GdkPixbuf, Gtk
class SpinnerImage(Gtk.Overlay): class SpinnerImage(Gtk.Overlay):
def __init__( def __init__(
self, self,
loading=True, loading: bool = True,
image_name=None, image_name: str = None,
spinner_name=None, spinner_name: str = None,
image_size=None, image_size: int = None,
**kwargs, **kwargs,
): ):
Gtk.Overlay.__init__(self) Gtk.Overlay.__init__(self)
self.image_size = image_size self.image_size = image_size
@@ -26,7 +28,7 @@ class SpinnerImage(Gtk.Overlay):
) )
self.add_overlay(self.spinner) self.add_overlay(self.spinner)
def set_from_file(self, filename): 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:
@@ -40,7 +42,7 @@ class SpinnerImage(Gtk.Overlay):
else: else:
self.image.set_from_file(filename) self.image.set_from_file(filename)
def set_loading(self, loading_status): def set_loading(self, loading_status: bool):
if loading_status: if loading_status:
self.spinner.start() self.spinner.start()
self.spinner.show() self.spinner.show()

View File

@@ -1,11 +1,12 @@
import subprocess import subprocess
from typing import Any
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject from gi.repository import GObject, Gtk
from sublime.config import AppConfiguration, ServerConfiguration
from sublime.server import Server from sublime.server import Server
from sublime.config import ServerConfiguration
from sublime.ui.common import EditFormDialog, IconButton from sublime.ui.common import EditFormDialog, IconButton
@@ -36,7 +37,7 @@ class EditServerDialog(EditFormDialog):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def on_test_server_clicked(self, event): 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(
@@ -72,7 +73,7 @@ class EditServerDialog(EditFormDialog):
dialog.run() dialog.run()
dialog.destroy() dialog.destroy()
def on_open_in_browser_clicked(self, event): 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()])
@@ -84,7 +85,7 @@ class ConfigureServersDialog(Gtk.Dialog):
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )), (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )),
} }
def __init__(self, parent, config): def __init__(self, parent: Any, config: AppConfiguration):
Gtk.Dialog.__init__( Gtk.Dialog.__init__(
self, self,
title='Configure Servers', title='Configure Servers',
@@ -116,13 +117,13 @@ class ConfigureServersDialog(Gtk.Dialog):
'document-edit-symbolic', 'document-edit-symbolic',
label='Edit...', label='Edit...',
relief=True, relief=True,
), lambda e: self.on_edit_clicked(e, False), 'start', True), ), lambda e: self.on_edit_clicked(False), 'start', True),
( (
IconButton( IconButton(
'list-add-symbolic', 'list-add-symbolic',
label='Add...', label='Add...',
relief=True, relief=True,
), lambda e: self.on_edit_clicked(e, True), 'start', False), ), lambda e: self.on_edit_clicked(True), 'start', False),
( (
IconButton( IconButton(
'list-remove-symbolic', 'list-remove-symbolic',
@@ -191,14 +192,14 @@ class ConfigureServersDialog(Gtk.Dialog):
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): 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, event, add): def on_edit_clicked(self, add: bool):
if add: if add:
dialog = EditServerDialog(self) dialog = EditServerDialog(self)
else: else:
@@ -236,12 +237,12 @@ class ConfigureServersDialog(Gtk.Dialog):
def on_server_list_activate(self, *args): def on_server_list_activate(self, *args):
self.on_connect_clicked(None) self.on_connect_clicked(None)
def on_connect_clicked(self, event): 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): def server_list_on_selected_rows_changed(self, event: Any):
# Update the state of the buttons depending on whether or not a row is # Update the state of the buttons depending on whether or not a row is
# selected in the server list. # selected in the server list.
has_selection = self.server_list.get_selected_row() has_selection = self.server_list.get_selected_row()

View File

@@ -1,15 +1,14 @@
from datetime import datetime from datetime import datetime
from typing import Set from typing import Any, Callable, Set
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, GObject, Gdk, GLib, Pango from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from . import albums, artists, browse, playlists, player_controls
from sublime.state_manager import ApplicationState
from sublime.cache_manager import CacheManager, SearchResult from sublime.cache_manager import CacheManager, SearchResult
from sublime.server.api_objects import Child from sublime.state_manager import ApplicationState
from sublime.ui import util from sublime.ui import (
albums, artists, browse, player_controls, playlists, util)
from sublime.ui.common import SpinnerImage from sublime.ui.common import SpinnerImage
@@ -43,7 +42,7 @@ class MainWindow(Gtk.ApplicationWindow):
self.set_default_size(1150, 768) self.set_default_size(1150, 768)
# Create the stack # Create the stack
self.stack = self.create_stack( self.stack = self._create_stack(
Albums=albums.AlbumsPanel(), Albums=albums.AlbumsPanel(),
Artists=artists.ArtistsPanel(), Artists=artists.ArtistsPanel(),
Browse=browse.BrowsePanel(), Browse=browse.BrowsePanel(),
@@ -52,7 +51,7 @@ class MainWindow(Gtk.ApplicationWindow):
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() self.player_controls = player_controls.PlayerControls()
@@ -70,9 +69,9 @@ class MainWindow(Gtk.ApplicationWindow):
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)
def update(self, state: ApplicationState, force=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 state.config.current_server >= 0:
server_name = state.config.servers[ server_name = state.config.servers[
@@ -91,7 +90,7 @@ class MainWindow(Gtk.ApplicationWindow):
self.player_controls.update(state) self.player_controls.update(state)
def create_stack(self, **kwargs): 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(
@@ -105,7 +104,7 @@ class MainWindow(Gtk.ApplicationWindow):
stack.add_titled(child, name.lower(), name) stack.add_titled(child, name.lower(), name)
return stack return stack
def create_headerbar(self, stack): def _create_headerbar(self, stack: Gtk.Stack) -> Gtk.HeaderBar:
""" """
Configure the header bar for the window. Configure the header bar for the window.
""" """
@@ -116,18 +115,19 @@ class MainWindow(Gtk.ApplicationWindow):
# 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(
'button-press-event', self.on_search_entry_button_press) 'focus-in-event', self._on_search_entry_focus)
self.search_entry.connect( self.search_entry.connect(
'focus-out-event', self.on_search_entry_loose_focus) 'button-press-event', self._on_search_entry_button_press)
self.search_entry.connect('changed', self.on_search_entry_changed)
self.search_entry.connect( self.search_entry.connect(
'stop-search', self.on_search_entry_stop_search) 'focus-out-event', self._on_search_entry_loose_focus)
self.search_entry.connect('changed', self._on_search_entry_changed)
self.search_entry.connect(
'stop-search', self._on_search_entry_stop_search)
header.pack_start(self.search_entry) header.pack_start(self.search_entry)
# Search popup # Search popup
self.create_search_popup() self._create_search_popup()
# Stack switcher # Stack switcher
switcher = Gtk.StackSwitcher(stack=stack) switcher = Gtk.StackSwitcher(stack=stack)
@@ -136,8 +136,8 @@ class MainWindow(Gtk.ApplicationWindow):
# Menu button # Menu button
menu_button = Gtk.MenuButton() menu_button = Gtk.MenuButton()
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')
@@ -148,7 +148,7 @@ class MainWindow(Gtk.ApplicationWindow):
return header return header
def create_label(self, text, *args, **kwargs): def _create_label(self, text: str, *args, **kwargs) -> Gtk.Label:
label = Gtk.Label( label = Gtk.Label(
use_markup=True, use_markup=True,
halign=Gtk.Align.START, halign=Gtk.Align.START,
@@ -160,10 +160,10 @@ class MainWindow(Gtk.ApplicationWindow):
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): 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>') f'<span style="italic">Not Connected to a Server</span>')
@@ -187,7 +187,7 @@ class MainWindow(Gtk.ApplicationWindow):
return self.menu return self.menu
def create_search_popup(self): def _create_search_popup(self) -> Gtk.PopoverMenu:
self.search_popup = Gtk.PopoverMenu(modal=False) self.search_popup = Gtk.PopoverMenu(modal=False)
results_scrollbox = Gtk.ScrolledWindow( results_scrollbox = Gtk.ScrolledWindow(
@@ -195,8 +195,8 @@ class MainWindow(Gtk.ApplicationWindow):
min_content_height=750, min_content_height=750,
) )
def make_search_result_header(text): 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
@@ -238,22 +238,22 @@ class MainWindow(Gtk.ApplicationWindow):
# Event Listeners # Event Listeners
# ========================================================================= # =========================================================================
def on_button_release(self, win, event): def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool:
if not self.event_in_widgets( if not self._event_in_widgets(
event, event,
self.search_entry, self.search_entry,
self.search_popup, 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,
@@ -262,25 +262,25 @@ class MainWindow(Gtk.ApplicationWindow):
return False return False
def on_menu_clicked(self, button): def _on_menu_clicked(self, *args):
self.menu.popup() self.menu.popup()
self.menu.show_all() self.menu.show_all()
def on_search_entry_focus(self, entry, event): def _on_search_entry_focus(self, *args):
self.show_search() self._show_search()
def on_search_entry_button_press(self, *args): def _on_search_entry_button_press(self, *args):
self.show_search() self._show_search()
def on_search_entry_loose_focus(self, entry, event): def _on_search_entry_loose_focus(self, *args):
self.hide_search() self._hide_search()
search_idx = 0 search_idx = 0
latest_returned_search_idx = 0 latest_returned_search_idx = 0
last_search_change_time = datetime.now() last_search_change_time = datetime.now()
searches: Set[SearchResult] = set() searches: Set[CacheManager.Result] = set()
def on_search_entry_changed(self, entry): def _on_search_entry_changed(self, entry: Gtk.Entry):
now = datetime.now() now = datetime.now()
if (now - self.last_search_change_time).seconds < 0.5: if (now - self.last_search_change_time).seconds < 0.5:
while len(self.searches) > 0: while len(self.searches) > 0:
@@ -293,8 +293,11 @@ class MainWindow(Gtk.ApplicationWindow):
self.search_popup.show_all() self.search_popup.show_all()
self.search_popup.popup() self.search_popup.popup()
def create_search_callback(idx): def create_search_callback(idx: int) -> Callable[..., Any]:
def search_result_calback(result, is_last_in_batch): def search_result_calback(
result: SearchResult,
is_last_in_batch: bool,
):
# Ignore slow returned searches. # Ignore slow returned searches.
if idx < self.latest_returned_search_idx: if idx < self.latest_returned_search_idx:
return return
@@ -302,10 +305,10 @@ class MainWindow(Gtk.ApplicationWindow):
# If all results are back, the stop the loading indicator. # If all results are back, the stop the loading indicator.
if is_last_in_batch: if is_last_in_batch:
if idx == self.search_idx - 1: if idx == self.search_idx - 1:
self.set_search_loading(False) self._set_search_loading(False)
self.latest_returned_search_idx = idx self.latest_returned_search_idx = idx
self.update_search_results(result) self._update_search_results(result)
return lambda *a: GLib.idle_add(search_result_calback, *a) return lambda *a: GLib.idle_add(search_result_calback, *a)
@@ -313,27 +316,27 @@ class MainWindow(Gtk.ApplicationWindow):
CacheManager.search( CacheManager.search(
entry.get_text(), entry.get_text(),
search_callback=create_search_callback(self.search_idx), search_callback=create_search_callback(self.search_idx),
before_download=lambda: self.set_search_loading(True), before_download=lambda: self._set_search_loading(True),
)) ))
self.search_idx += 1 self.search_idx += 1
def on_search_entry_stop_search(self, entry): def _on_search_entry_stop_search(self, entry: Any):
self.search_popup.popdown() self.search_popup.popdown()
# Helper Functions # Helper Functions
# ========================================================================= # =========================================================================
def show_search(self): def _show_search(self):
self.search_entry.set_size_request(300, -1) self.search_entry.set_size_request(300, -1)
self.search_popup.show_all() self.search_popup.show_all()
self.search_results_loading.hide() self.search_results_loading.hide()
self.search_popup.popup() self.search_popup.popup()
def hide_search(self): def _hide_search(self):
self.search_popup.popdown() self.search_popup.popdown()
self.search_entry.set_size_request(-1, -1) self.search_entry.set_size_request(-1, -1)
def set_search_loading(self, loading_state): def _set_search_loading(self, loading_state: bool):
if loading_state: if loading_state:
self.search_results_loading.start() self.search_results_loading.start()
self.search_results_loading.show_all() self.search_results_loading.show_all()
@@ -341,24 +344,24 @@ class MainWindow(Gtk.ApplicationWindow):
self.search_results_loading.stop() self.search_results_loading.stop()
self.search_results_loading.hide() self.search_results_loading.hide()
def remove_all_from_widget(self, widget): def _remove_all_from_widget(self, widget: Gtk.Widget):
for c in widget.get_children(): for c in widget.get_children():
widget.remove(c) widget.remove(c)
def create_search_result_row( def _create_search_result_row(
self, self,
text, text: str,
action_name, action_name: str,
value, value: Any,
artwork_future, artwork_future: CacheManager.Result,
): ) -> Gtk.Button:
def on_search_row_button_press(btn, event): def on_search_row_button_press(*args):
if action_name == 'song': if action_name == 'song':
goto_action_name, goto_id = 'album', value.albumId goto_action_name, goto_id = 'album', value.albumId
else: else:
goto_action_name, goto_id = action_name, value.id goto_action_name, goto_id = action_name, value.id
self.emit('go-to', goto_action_name, goto_id) self.emit('go-to', goto_action_name, goto_id)
self.hide_search() 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)
@@ -366,10 +369,10 @@ class MainWindow(Gtk.ApplicationWindow):
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): def image_callback(f: CacheManager.Result):
image.set_loading(False) image.set_loading(False)
image.set_from_file(f.result()) image.set_from_file(f.result())
@@ -378,10 +381,10 @@ class MainWindow(Gtk.ApplicationWindow):
return row return row
def update_search_results(self, search_results): def _update_search_results(self, search_results: SearchResult):
# Songs # Songs
if search_results.song is not None: if search_results.song 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.song or []:
label_text = util.dot_join( label_text = util.dot_join(
f'<b>{util.esc(song.title)}</b>', f'<b>{util.esc(song.title)}</b>',
@@ -390,54 +393,53 @@ class MainWindow(Gtk.ApplicationWindow):
cover_art_future = CacheManager.get_cover_art_filename( cover_art_future = CacheManager.get_cover_art_filename(
song.coverArt, size=50) song.coverArt, size=50)
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, 'song', song, cover_art_future))
self.song_results.show_all() self.song_results.show_all()
# Albums # Albums
if search_results.album is not None: if search_results.album 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.album or []:
name = album.title if type(album) == Child else album.name
label_text = util.dot_join( label_text = util.dot_join(
f'<b>{util.esc(name)}</b>', f'<b>{util.esc(album.name)}</b>',
util.esc(album.artist), util.esc(album.artist),
) )
cover_art_future = CacheManager.get_cover_art_filename( cover_art_future = CacheManager.get_cover_art_filename(
album.coverArt, size=50) album.coverArt, size=50)
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, cover_art_future))
self.album_results.show_all() self.album_results.show_all()
# Artists # Artists
if search_results.artist is not None: if search_results.artist 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.artist or []:
label_text = util.esc(artist.name) label_text = util.esc(artist.name)
cover_art_future = CacheManager.get_artist_artwork(artist) cover_art_future = CacheManager.get_artist_artwork(artist)
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, cover_art_future))
self.artist_results.show_all() self.artist_results.show_all()
# Playlists # Playlists
if search_results.playlist is not None: if search_results.playlist is not None:
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.playlist or []:
label_text = util.esc(playlist.name) label_text = util.esc(playlist.name)
cover_art_future = CacheManager.get_cover_art_filename( cover_art_future = CacheManager.get_cover_art_filename(
playlist.coverArt) 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, cover_art_future))
self.playlist_results.show_all() self.playlist_results.show_all()
def event_in_widgets(self, event, *widgets): def _event_in_widgets(self, event: Gdk.EventButton, *widgets) -> bool:
for widget in widgets: for widget in widgets:
if not widget.is_visible(): if not widget.is_visible():
continue continue

View File

@@ -2,17 +2,18 @@ import math
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Optional
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GdkPixbuf, Pango, GObject, GLib from gi.repository import GdkPixbuf, GLib, GObject, Gtk, Pango
from pychromecast import Chromecast
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.players import ChromecastPlayer
from sublime.state_manager import ApplicationState, RepeatType from sublime.state_manager import ApplicationState, RepeatType
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import IconButton, SpinnerImage from sublime.ui.common import IconButton, SpinnerImage
from sublime.players import ChromecastPlayer
class PlayerControls(Gtk.ActionBar): class PlayerControls(Gtk.ActionBar):
@@ -169,7 +170,7 @@ class PlayerControls(Gtk.ActionBar):
new_store = [] new_store = []
def calculate_label(song_details): def calculate_label(song_details) -> str:
title = util.esc(song_details.title) title = util.esc(song_details.title)
album = util.esc(song_details.album) album = util.esc(song_details.album)
artist = util.esc(song_details.artist) artist = util.esc(song_details.artist)
@@ -270,9 +271,9 @@ class PlayerControls(Gtk.ActionBar):
def update_cover_art( def update_cover_art(
self, self,
cover_art_filename: str, cover_art_filename: str,
state, state: ApplicationState,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
if order_token != self.cover_art_update_order_token: if order_token != self.cover_art_update_order_token:
return return
@@ -320,10 +321,10 @@ class PlayerControls(Gtk.ActionBar):
{'no_reshuffle': True}, {'no_reshuffle': True},
) )
def update_device_list(self, force=False): def update_device_list(self, force: bool = False):
self.device_list_loading.show() self.device_list_loading.show()
def chromecast_callback(chromecasts): def chromecast_callback(chromecasts: List[Chromecast]):
self.chromecasts = chromecasts self.chromecasts = chromecasts
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)

View File

@@ -1,16 +1,15 @@
from functools import lru_cache from functools import lru_cache
from random import randint from random import randint
from typing import List from typing import Any, Iterable, List, Optional, Tuple
from fuzzywuzzy import process
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, Pango, GObject, GLib from fuzzywuzzy import process
from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager
from sublime.server.api_objects import PlaylistWithSongs from sublime.server.api_objects import PlaylistWithSongs
from sublime.state_manager import ApplicationState from sublime.state_manager import ApplicationState
from sublime.cache_manager import CacheManager
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import EditFormDialog, IconButton, SpinnerImage from sublime.ui.common import EditFormDialog, IconButton, SpinnerImage
@@ -21,7 +20,7 @@ class EditPlaylistDialog(EditFormDialog):
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, playlist_id=None, **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)
@@ -59,7 +58,7 @@ class PlaylistsPanel(Gtk.Paned):
) )
self.pack2(self.playlist_detail_panel, True, False) self.pack2(self.playlist_detail_panel, True, False)
def update(self, state: ApplicationState = None, force=False): def update(self, state: ApplicationState = None, force: bool = False):
self.playlist_list.update(state=state, force=force) self.playlist_list.update(state=state, force=force)
self.playlist_detail_panel.update(state=state, force=force) self.playlist_detail_panel.update(state=state, force=force)
@@ -77,7 +76,7 @@ class PlaylistList(Gtk.Box):
playlist_id = GObject.Property(type=str) playlist_id = GObject.Property(type=str)
name = GObject.Property(type=str) name = GObject.Property(type=str)
def __init__(self, playlist_id, name): def __init__(self, playlist_id: str, name: str):
GObject.GObject.__init__(self) GObject.GObject.__init__(self)
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.name = name self.name = name
@@ -144,7 +143,8 @@ 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(model: PlaylistList.PlaylistModel): def create_playlist_row(
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),
@@ -180,8 +180,8 @@ class PlaylistList(Gtk.Box):
self, self,
playlists: List[PlaylistWithSongs], playlists: List[PlaylistWithSongs],
state: ApplicationState, state: ApplicationState,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
new_store = [] new_store = []
selected_idx = None selected_idx = None
@@ -203,25 +203,25 @@ class PlaylistList(Gtk.Box):
# Event Handlers # Event Handlers
# ========================================================================= # =========================================================================
def on_new_playlist_clicked(self, new_playlist_button): 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()
def on_list_refresh_click(self, button): def on_list_refresh_click(self, _: Any):
self.update(force=True) self.update(force=True)
def new_entry_activate(self, entry): def new_entry_activate(self, entry: Gtk.Entry):
self.create_playlist(entry.get_text()) self.create_playlist(entry.get_text())
def cancel_button_clicked(self, button): def cancel_button_clicked(self, _: Any):
self.new_playlist_row.hide() self.new_playlist_row.hide()
def confirm_button_clicked(self, button): def confirm_button_clicked(self, _: Any):
self.create_playlist(self.new_playlist_entry.get_text()) self.create_playlist(self.new_playlist_entry.get_text())
def create_playlist(self, playlist_name): def create_playlist(self, playlist_name: str):
def on_playlist_created(f): def on_playlist_created(_: Any):
CacheManager.invalidate_playlists_cache() CacheManager.invalidate_playlists_cache()
self.update(force=True) self.update(force=True)
@@ -339,7 +339,13 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Playlist songs list # Playlist songs list
playlist_view_scroll_window = Gtk.ScrolledWindow() playlist_view_scroll_window = Gtk.ScrolledWindow()
def create_column(header, text_idx, bold=False, align=0, width=None): def create_column(
header: str,
text_idx: int,
bold: bool = False,
align: int = 0,
width: int = None,
):
renderer = Gtk.CellRendererText( renderer = Gtk.CellRendererText(
xalign=align, xalign=align,
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL, weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
@@ -362,11 +368,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
@lru_cache(maxsize=1024) @lru_cache(maxsize=1024)
def row_score(key, row_items): def row_score(key: str, row_items: Iterable[str]) -> int:
return max(map(lambda m: m[1], process.extract(key, row_items))) return max(map(lambda m: m[1], process.extract(key, row_items)))
@lru_cache() @lru_cache()
def max_score_for_key(key, rows): def max_score_for_key(key: str, rows: Tuple) -> int:
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(model, col, key, treeiter, data=None): def playlist_song_list_search_fn(model, col, key, treeiter, data=None):
@@ -433,7 +439,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
update_playlist_view_order_token = 0 update_playlist_view_order_token = 0
def update(self, state: ApplicationState, force=False): def update(self, state: ApplicationState, force: bool = False):
if state.selected_playlist_id is None: if 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('')
@@ -460,10 +466,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
def update_playlist_view( def update_playlist_view(
self, self,
playlist, playlist: PlaylistWithSongs,
state: ApplicationState = None, state: ApplicationState = None,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
if self.update_playlist_view_order_token != order_token: if self.update_playlist_view_order_token != order_token:
return return
@@ -483,7 +489,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_comment.show() self.playlist_comment.show()
else: else:
self.playlist_comment.hide() self.playlist_comment.hide()
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(
@@ -522,10 +528,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
def update_playlist_artwork( def update_playlist_artwork(
self, self,
cover_art_filename, cover_art_filename: str,
state: ApplicationState, state: ApplicationState,
force=False, force: bool = False,
order_token=None, order_token: Optional[int] = None,
): ):
if self.update_playlist_view_order_token != order_token: if self.update_playlist_view_order_token != order_token:
return return
@@ -535,18 +541,17 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Event Handlers # Event Handlers
# ========================================================================= # =========================================================================
def on_view_refresh_click(self, button): def on_view_refresh_click(self, _: Any):
self.update_playlist_view( self.update_playlist_view(
self.playlist_id, self.playlist_id,
force=True, force=True,
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
) )
def on_playlist_edit_button_click(self, button): def on_playlist_edit_button_click(self, _: Any):
dialog = EditPlaylistDialog( dialog = EditPlaylistDialog(
self.get_toplevel(), self.get_toplevel(),
CacheManager.get_playlist(self.playlist_id).result(), CacheManager.get_playlist(self.playlist_id).result(),
playlist_id=self.playlist_id,
) )
result = dialog.run() result = dialog.run()
@@ -577,7 +582,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
dialog.destroy() dialog.destroy()
def on_playlist_list_download_all_button_click(self, button): def on_playlist_list_download_all_button_click(self, _: Any):
def download_state_change(*args): def download_state_change(*args):
GLib.idle_add( GLib.idle_add(
lambda: self.update_playlist_view( lambda: self.update_playlist_view(
@@ -592,7 +597,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
on_song_download_complete=download_state_change, on_song_download_complete=download_state_change,
) )
def on_play_all_clicked(self, btn): def on_play_all_clicked(self, _: Any):
self.emit( self.emit(
'song-clicked', 'song-clicked',
0, 0,
@@ -603,7 +608,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
}, },
) )
def on_shuffle_all_button(self, btn): def on_shuffle_all_button(self, _: Any):
self.emit( self.emit(
'song-clicked', 'song-clicked',
randint(0, randint(0,
@@ -635,7 +640,7 @@ 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(song_id=None): def on_download_state_change(**kwargs):
GLib.idle_add( GLib.idle_add(
lambda: self.update_playlist_view( lambda: self.update_playlist_view(
self.playlist_id, self.playlist_id,
@@ -656,7 +661,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
widget_coords = tree.convert_tree_to_widget_coords( widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y) event.x, event.y)
def on_remove_songs_click(button): def on_remove_songs_click(_: Any):
CacheManager.update_playlist( CacheManager.update_playlist(
playlist_id=self.playlist_id, playlist_id=self.playlist_id,
song_index_to_remove=[p.get_indices()[0] for p in paths], song_index_to_remove=[p.get_indices()[0] for p in paths],
@@ -694,7 +699,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
# which one comes first, but just in case, we have this # which one comes first, but just in case, we have this
# reordering_playlist_song_list flag. # reordering_playlist_song_list flag.
if self.reordering_playlist_song_list: if self.reordering_playlist_song_list:
self.update_playlist_order(self.playlist_id) self._update_playlist_order(self.playlist_id)
self.reordering_playlist_song_list = False self.reordering_playlist_song_list = False
else: else:
self.reordering_playlist_song_list = True self.reordering_playlist_song_list = True
@@ -705,7 +710,12 @@ 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(self, text=None, name=None, **params): def make_label(
self,
text: str = None,
name: str = None,
**params,
) -> Gtk.Label:
return Gtk.Label( return Gtk.Label(
label=text, label=text,
name=name, name=name,
@@ -714,7 +724,12 @@ class PlaylistDetailPanel(Gtk.Overlay):
) )
@util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k)) @util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k))
def update_playlist_order(self, playlist, state, **kwargs): def _update_playlist_order(
self,
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 = CacheManager.update_playlist(
playlist_id=playlist.id, playlist_id=playlist.id,
@@ -730,7 +745,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
order_token=self.update_playlist_view_order_token, order_token=self.update_playlist_view_order_token,
))) )))
def format_stats(self, playlist): def _format_stats(self, playlist: PlaylistWithSongs) -> str:
created_date = playlist.created.strftime('%B %d, %Y') created_date = playlist.created.strftime('%B %d, %Y')
lines = [ lines = [
util.dot_join( util.dot_join(

View File

@@ -1,19 +1,18 @@
import functools import functools
from typing import Callable, List, Tuple, Any
import re import re
from concurrent.futures import Future from concurrent.futures import Future
from typing import Any, Callable, cast, List, Match, Optional, Tuple, Union
from deepdiff import DeepDiff
import gi import gi
from deepdiff import DeepDiff
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib, Gdk from gi.repository import Gdk, GLib, Gtk
from sublime.cache_manager import CacheManager, SongCacheStatus from sublime.cache_manager import CacheManager, SongCacheStatus
from sublime.state_manager import ApplicationState
def format_song_duration(duration_secs) -> str: def format_song_duration(duration_secs: int) -> 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.
@@ -26,7 +25,11 @@ def format_song_duration(duration_secs) -> str:
return f'{duration_secs // 60}:{duration_secs % 60:02}' return f'{duration_secs // 60}:{duration_secs % 60:02}'
def pluralize(string: str, number: int, pluralized_form=None): def pluralize(
string: str,
number: int,
pluralized_form: Optional[str] = None,
) -> str:
""" """
Pluralize the given string given the count as a number. Pluralize the given string given the count as a number.
@@ -42,7 +45,7 @@ def pluralize(string: str, number: int, pluralized_form=None):
return string return string
def format_sequence_duration(duration_secs) -> str: def format_sequence_duration(duration_secs: int) -> str:
""" """
Formats duration in English. Formats duration in English.
@@ -76,20 +79,20 @@ def format_sequence_duration(duration_secs) -> str:
return ', '.join(format_components) return ', '.join(format_components)
def esc(string): def esc(string: str) -> str:
if string is None: if string is None:
return None return None
return string.replace('&', '&amp;').replace(" target='_blank'", '') return string.replace('&', '&amp;').replace(" target='_blank'", '')
def dot_join(*items): 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.
""" """
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): def get_cached_status_icon(cache_status: SongCacheStatus) -> str:
cache_icon = { cache_icon = {
SongCacheStatus.NOT_CACHED: '', SongCacheStatus.NOT_CACHED: '',
SongCacheStatus.CACHED: 'folder-download-symbolic', SongCacheStatus.CACHED: 'folder-download-symbolic',
@@ -99,9 +102,9 @@ def get_cached_status_icon(cache_status: SongCacheStatus):
return cache_icon[cache_status] return cache_icon[cache_status]
def _parse_diff_location(location): def _parse_diff_location(location: str):
match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location) match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location)
return tuple(g for g in match.groups() if g is not None) return tuple(g for g in cast(Match, match).groups() if g is not None)
def diff_song_store(store_to_edit, new_store): def diff_song_store(store_to_edit, new_store):
@@ -302,9 +305,9 @@ def show_song_popover(
def async_callback( def async_callback(
future_fn, future_fn: Callable[..., Future],
before_download=None, before_download: Callable[[Any], None] = None,
on_failure=None, on_failure: Callable[[Any, Exception], None] = None,
): ):
""" """
Defines the ``async_callback`` decorator. Defines the ``async_callback`` decorator.
@@ -315,16 +318,16 @@ def async_callback(
by said lambda function. by said lambda function.
:param future_fn: a function which generates a :param future_fn: a function which generates a
``concurrent.futures.Future``. :class:`concurrent.futures.Future` or :class:`CacheManager.Result`.
""" """
def decorator(callback_fn): def decorator(callback_fn):
@functools.wraps(callback_fn) @functools.wraps(callback_fn)
def wrapper( def wrapper(
self, self,
*args, *args,
state=None, state: ApplicationState = None,
order_token=None, force: bool = False,
force=False, order_token: Optional[int] = None,
**kwargs, **kwargs,
): ):
if before_download: if before_download:
@@ -333,15 +336,15 @@ def async_callback(
else: else:
on_before_download = (lambda: None) on_before_download = (lambda: None)
def future_callback(f): def future_callback(f: Union[Future, CacheManager.Result]):
try: try:
result = f.result() result = f.result()
except Exception as e: except Exception as e:
if on_failure: if on_failure:
on_failure(self, e) GLib.idle_add(on_failure, self, e)
return return
return GLib.idle_add( GLib.idle_add(
lambda: callback_fn( lambda: callback_fn(
self, self,
result, result,
@@ -350,7 +353,7 @@ def async_callback(
order_token=order_token, order_token=order_token,
)) ))
future: Future = future_fn( future: Union[Future, CacheManager.Result] = future_fn(
*args, *args,
before_download=on_before_download, before_download=on_before_download,
force=force, force=force,