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
[dev-packages]
docutils = "*"
flake8 = "*"
flake8-annotations = "*"
flake8-comprehensions = "*"
flake8-pep3101 = "*"
flake8-print = "*"
graphviz = "*"
jedi = "*"
lxml = "*"
mypy = "*"
yapf = "*"
pytest = "*"
pytest-cov = "*"
docutils = "*"
lxml = "*"
jedi = "*"
rope = "*"
rst2html5 = "*"
graphviz = "*"
sphinx = "*"
sphinx-rtd-theme = "*"
sphinx-autodoc-typehints = "*"
sphinx-rtd-theme = "*"
termcolor = "*"
yapf = "*"
[packages]
sublime-music = {editable = true,path = "."}

45
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "c16627b97b66d2ad7016bd43428e26f5f29836ba28eb797d27c4cc80f8a70a99"
"sha256": "1697c1d3c4480dbec759d96e80b367e508d813cb2183a1c6226f56ee0be8fac6"
},
"pipfile-spec": 6,
"requires": {
@@ -383,6 +383,37 @@
"index": "pypi",
"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": {
"hashes": [
"sha256:5e92e278ca1ea395349a451d54fc81dc3c1b543c48939a15bd36b7b3335e1560",
@@ -659,11 +690,11 @@
},
"sphinx": {
"hashes": [
"sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f",
"sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9"
"sha256:776ff8333181138fae52df65be733127539623bb46cc692e7fa0fcfc80d7aa88",
"sha256:ca762da97c3b5107cbf0ab9e11d3ec7ab8d3c31377266fd613b962ed971df709"
],
"index": "pypi",
"version": "==2.4.2"
"version": "==2.4.3"
},
"sphinx-autodoc-typehints": {
"hashes": [
@@ -697,10 +728,10 @@
},
"sphinxcontrib-htmlhelp": {
"hashes": [
"sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422",
"sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"
"sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f",
"sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"
],
"version": "==1.0.2"
"version": "==1.0.3"
},
"sphinxcontrib-jsmath": {
"hashes": [

View File

@@ -1,14 +1,16 @@
#! /usr/bin/env python3
"""
Autogenerates Python classes for Subsonic API objects.
This program constructs a dependency graph of all of the entities defined by a
Subsonic REST API XSD file. It then uses that graph to generate code which
represents those API objects in Python.
"""
import re
from collections import defaultdict
from typing import cast, Dict, DefaultDict, Set, Match, Tuple, List
import sys
from collections import defaultdict
from typing import DefaultDict, Dict, List, Set, Tuple
from graphviz import Digraph
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')
for type_, deps in dependency_graph.items():
for type_, deps in graph.items():
g.node(type_)
for dep in deps:
@@ -39,21 +42,27 @@ def render_digraph(graph, filename):
g.render()
def primitive_translate(type_str):
def primitive_translate(type_str: str) -> str:
# Translate the primitive values, but default to the actual value.
return primitive_translation_map.get(type_str, type_str)
def extract_type(type_str):
return primitive_translate(
cast(Match, element_type_re.match(type_str)).group(1))
def extract_type(type_str: str) -> str:
match = element_type_re.match(type_str)
if not match:
raise Exception(f'Could not extract type from string "{type_str}"')
return primitive_translate(match.group(1))
def extract_tag_type(tag_type_str):
return cast(Match, tag_type_re.match(tag_type_str)).group(1)
def extract_tag_type(tag_type_str: str) -> str:
match = tag_type_re.match(tag_type_str)
if not match:
raise Exception(
f'Could not extract tag type from string "{tag_type_str}"')
return match.group(1)
def get_dependencies(xs_el) -> 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
object for embedding in other objects.
@@ -162,7 +171,7 @@ def get_dependencies(xs_el) -> Tuple[Set[str], Dict[str, str]]:
# Check arguments.
# =============================================================================
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)
schema_file, output_file = sys.argv[1:]
@@ -201,7 +210,7 @@ seen: Set[str] = set()
i = 0
def dfs(g, el):
def dfs(g: DefaultDict[str, Set[str]], el: str):
global i
if el in seen:
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]
code = ['', '']
@@ -262,12 +271,12 @@ def generate_class_for_type(type_name):
# Auto-generated __eq__ and __hash__ functions if there's an ID field.
if not is_enum and has_properties and 'id' in fields:
code.append('')
code.append(indent_str.format('def __eq__(self, other):'))
code.append(indent_str.format('def __eq__(self, other: Any) -> bool:'))
code.append(indent_str.format(' return hash(self) == hash(other)'))
hash_name = inherits or type_name
code.append('')
code.append(indent_str.format('def __hash__(self):'))
code.append(indent_str.format('def __hash__(self) -> int:'))
code.append(
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 typing import List',
'from enum import Enum',
'from typing import Any, List',
'',
'from sublime.server.api_object import APIObject',
*map(generate_class_for_type, output_order),
]) + '\n')

View File

@@ -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
import sys
import re
import sys
from pathlib import Path
from termcolor import cprint
print_re = re.compile(r'print\(.*\)')
todo_re = re.compile(r'#\s*TODO:?\s*')
accounted_for_todo = re.compile(r'#\s*TODO:?\s*\((#\d+)\)')
def check_file(path):
print(f'Checking {path.absolute()}...')
def check_file(path: Path) -> bool:
print(f'Checking {path.absolute()}...') # noqa: T001
file = path.open()
valid = True
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):
cprint(f'{i}: {line}', 'red', end='', attrs=['bold'])
valid = False
@@ -33,6 +27,6 @@ def check_file(path):
valid = True
for path in Path('sublime').glob('**/*.py'):
valid &= check_file(path)
print()
print() # noqa: T001
sys.exit(0 if valid else 1)

View File

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

View File

@@ -1,14 +1,14 @@
#! /usr/bin/env python3
import os
import argparse
import logging
import os
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk # noqa: F401
import sublime
from .app import SublimeMusicApp
from sublime.app import SublimeMusicApp
def main():
@@ -39,7 +39,7 @@ def main():
args, unknown_args = parser.parse_known_args()
if args.version:
print(f'Sublime Music v{sublime.__version__}') # allowprint
print(f'Sublime Music v{sublime.__version__}') # noqa: T001
return
min_log_level = getattr(logging, args.loglevel.upper(), None)

View File

@@ -1,26 +1,25 @@
import os
import logging
import math
import os
import random
import gi
gi.require_version('Gtk', '3.0')
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 .dbus_manager import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, PlayerEvent
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):
def __init__(self, config_file):
def __init__(self, config_file: str):
super().__init__(application_id="com.sumnerevans.sublimemusic")
Notify.init('Sublime Music')
@@ -912,11 +911,12 @@ class SublimeMusicApp(Gtk.Application):
song_idx = self.state.play_queue.index(song.id)
prefetch_idxs = []
for i in range(self.state.config.prefetch_amount):
prefetch_idx = song_idx + 1 + i
play_queue_len = len(self.state.play_queue)
prefetch_idx: int = song_idx + 1 + i
play_queue_len: int = len(self.state.play_queue)
if (self.state.repeat_type == RepeatType.REPEAT_QUEUE
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(
[self.state.play_queue[i] for i in prefetch_idxs],
before_download=lambda: GLib.idle_add(self.update_window),

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,19 @@
import typing
from datetime import datetime
from enum import EnumMeta
import typing
from typing import Dict, List, Type
from typing import Any, Dict, Type
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:
cls: the template class to deserialize into
template_type: the template type to deserialize into
data: the data to deserialize to the class
"""
# 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
# type. This allows for types that have to be put into a string.
if isinstance(cls, typing.ForwardRef):
cls = cls._evaluate(globals(), locals())
if isinstance(template_type, typing.ForwardRef): # type: ignore
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
instance: Any = None
if data is None:
instance = None
# 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.
class_name = cls._name
class_name = template_type._name
# This is not very elegant since it doesn't allow things which sublass
# from List or Dict. For my purposes, this doesn't matter.
if class_name == 'List':
list_type = cls.__args__[0]
instance: List[list_type] = list()
for value in data:
instance.append(from_json(list_type, value))
inner_type = template_type.__args__[0]
instance = [from_json(inner_type, value) for value in data]
elif class_name == 'Dict':
key_type, val_type = cls.__args__
instance: Dict[key_type, val_type] = dict()
for key, value in data.items():
key = from_json(key_type, key)
value = from_json(val_type, value)
instance[key] = value
key_type, val_type = template_type.__args__
instance = {
from_json(key_type, key): from_json(val_type, value)
for key, value in data.items()
}
else:
raise Exception(
f'Trying to deserialize an unsupported type: {cls._name}')
elif cls == str or issubclass(cls, str):
'Trying to deserialize an unsupported type: {}'.format(
template_type._name))
elif template_type == str or issubclass(template_type, str):
instance = data
elif cls == int or issubclass(cls, int):
elif template_type == int or issubclass(template_type, int):
instance = int(data)
elif cls == bool or issubclass(cls, bool):
elif template_type == bool or issubclass(template_type, bool):
instance = bool(data)
elif type(cls) == EnumMeta:
elif type(template_type) == EnumMeta:
if type(data) == dict:
instance = cls(data.get('_value_'))
instance = template_type(data.get('_value_'))
else:
instance = cls(data)
elif cls == datetime:
instance = template_type(data)
elif template_type == datetime:
if type(data) == int:
instance = datetime.fromtimestamp(data / 1000)
else:
@@ -72,7 +72,7 @@ def from_json(cls, data):
# Handle everything else by first instantiating the class, then adding
# all of the sub-elements, recursively calling from_json on them.
else:
instance: cls = cls()
instance = template_type()
for field, field_type in annotations.items():
value = data.get(field)
setattr(instance, field, from_json(field_type, value))

View File

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

View File

@@ -10,7 +10,7 @@ class APIObject:
this only supports JSON.
"""
@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
object. This calls the :class:`sublime.from_json.from_json` function
@@ -21,7 +21,7 @@ class APIObject:
"""
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``.
@@ -30,7 +30,7 @@ class APIObject:
"""
return getattr(self, field, default)
def __repr__(self):
def __repr__(self) -> str:
if isinstance(self, Enum):
return super().__repr__()
if isinstance(self, str):

View File

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

View File

@@ -1,14 +1,13 @@
import logging
import math
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 time import sleep
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urlencode
import requests
from deprecated import deprecated
from .api_objects import (
AlbumInfo,
@@ -22,6 +21,7 @@ from .api_objects import (
Bookmarks,
Child,
Directory,
Error,
Genres,
Indexes,
InternetRadioStations,
@@ -38,9 +38,9 @@ from .api_objects import (
SearchResult2,
SearchResult3,
Shares,
Songs,
Starred,
Starred2,
Songs,
User,
Users,
VideoInfo,
@@ -61,6 +61,10 @@ class Server:
* The ``server`` module is stateless. The only thing that it does is allow
the module's user to query the \\*sonic server via the API.
"""
class SubsonicServerError(Exception):
def __init__(self: 'Server.SubsonicServerError', error: Error):
super().__init__(f'{error.code}: {error.message}')
def __init__(
self,
name: str,
@@ -77,22 +81,19 @@ class Server:
def _get_params(self) -> Dict[str, str]:
"""See Subsonic API Introduction for details."""
return dict(
u=self.username,
p=self.password,
c='Sublime Music',
f='json',
v='1.15.0',
)
return {
'u': self.username,
'p': self.password,
'c': 'Sublime Music',
'f': 'json',
'v': '1.15.0',
}
def _make_url(self, endpoint: str) -> str:
return f'{self.hostname}/rest/{endpoint}.view'
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, **params):
def _get(self, url: str, **params) -> Any:
params = {**self._get_params(), **params}
logging.info(f'[START] get: {url}')
@@ -105,7 +106,7 @@ class Server:
# Deal with datetime parameters (convert to milliseconds since 1970)
for k, v in params.items():
if type(v) == datetime:
params[k] = int(cast(datetime, v).timestamp() * 1000)
params[k] = int(v.timestamp() * 1000)
result = requests.get(
url,
@@ -151,11 +152,11 @@ class Server:
# Check for an error and if it exists, raise it.
if response.get('error'):
raise self._subsonic_error_to_exception(response.error)
raise Server.SubsonicServerError(response.error)
return response
def do_download(self, url, **params) -> bytes:
def do_download(self, url: str, **params) -> bytes:
download = self._get(url, **params)
if 'json' in download.headers.get('Content-Type'):
# TODO (#122): make better
@@ -201,7 +202,7 @@ class Server:
ifModifiedSince=if_modified_since)
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
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)
def get_stream_url(
self,
id: str,
max_bit_rate: int = None,
format: str = None,
time_offset: int = None,
size: int = None,
estimate_content_length: bool = False,
converted: bool = False,
):
self,
id: str,
max_bit_rate: int = None,
format: str = None,
time_offset: int = None,
size: int = None,
estimate_content_length: 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.
Obtained by calls to ``getMusicDirectory``.
@@ -829,7 +830,7 @@ class Server:
params = {k: v for k, v in params.items() if v}
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
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)
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.
@@ -849,7 +850,7 @@ class Server:
return self.do_download(
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.
@@ -874,7 +875,7 @@ class Server:
)
return result.lyrics
def get_avatar(self, username: str):
def get_avatar(self, username: str) -> bytes:
"""
Returns the avatar (personal image) for a user.

View File

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

View File

@@ -1,18 +1,16 @@
import logging
from typing import Any, Callable, Iterable, Optional, Tuple, Union
import gi
from typing import Union
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.server.api_objects import AlbumWithSongsID3, Child
from sublime.state_manager import ApplicationState
from sublime.ui import util
from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage
from sublime.server.api_objects import Child, AlbumWithSongsID3
Album = Union[Child, AlbumWithSongsID3]
@@ -97,7 +95,11 @@ class AlbumsPanel(Gtk.Box):
scrolled_window.add(self.grid)
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)
for item in items:
store.append(item)
@@ -120,7 +122,7 @@ class AlbumsPanel(Gtk.Box):
if not CacheManager.ready():
return
def get_genres_done(f):
def get_genres_done(f: CacheManager.Result):
try:
new_store = [
(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)
# Show/hide the combo boxes.
def show_if(sort_type, *elements):
def show_if(sort_type: str, *elements):
for element in elements:
if state.current_album_sort == sort_type:
element.show()
@@ -175,15 +177,16 @@ class AlbumsPanel(Gtk.Box):
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()
if tree_iter is not None:
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)
def on_type_combo_changed(self, combo):
def on_type_combo_changed(self, combo: Gtk.ComboBox):
new_active_sort = self.get_id(combo)
self.grid.update_params(type_=new_active_sort)
self.emit_if_not_updating(
@@ -195,7 +198,7 @@ class AlbumsPanel(Gtk.Box):
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)
self.grid.update_params(alphabetical_type=new_active_alphabetical_sort)
self.emit_if_not_updating(
@@ -208,7 +211,7 @@ class AlbumsPanel(Gtk.Box):
False,
)
def on_genre_change(self, combo):
def on_genre_change(self, combo: Gtk.ComboBox):
new_active_genre = self.get_id(combo)
self.grid.update_params(genre=new_active_genre)
self.emit_if_not_updating(
@@ -220,7 +223,7 @@ class AlbumsPanel(Gtk.Box):
True,
)
def on_year_changed(self, entry):
def on_year_changed(self, entry: Gtk.Entry):
try:
year = int(entry.get_text())
except Exception:
@@ -250,7 +253,7 @@ class AlbumsPanel(Gtk.Box):
True,
)
def on_grid_cover_clicked(self, grid, id):
def on_grid_cover_clicked(self, grid: Any, id: str):
self.emit(
'refresh-window',
{'selected_album_id': id},
@@ -263,19 +266,6 @@ class AlbumsPanel(Gtk.Box):
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):
"""Defines the albums panel."""
__gsignals__ = {
@@ -303,6 +293,18 @@ class AlbumsGrid(Gtk.Overlay):
overshoot_update_in_progress = False
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(
self,
type_: str = None,
@@ -408,12 +410,12 @@ class AlbumsGrid(Gtk.Overlay):
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():
self.spinner.hide()
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)
self.current_selection = selected_index
self.reflow_grids(
@@ -434,7 +436,7 @@ class AlbumsGrid(Gtk.Overlay):
require_reflow = self.parameters_changed
self.parameters_changed = False
def do_update(f):
def do_update(f: CacheManager.Result):
try:
albums = f.result()
except Exception as e:
@@ -461,7 +463,7 @@ class AlbumsGrid(Gtk.Overlay):
selected_index = None
for i, album in enumerate(albums):
model = AlbumModel(album)
model = AlbumsGrid.AlbumModel(album)
if model.id == selected_id:
selected_index = i
@@ -485,7 +487,7 @@ class AlbumsGrid(Gtk.Overlay):
# 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
selected_index = (
child.get_index() + (0 if click_top else len(self.list_store_top)))
@@ -495,7 +497,7 @@ class AlbumsGrid(Gtk.Overlay):
else:
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.
# 200 + (10 * 2) + (5 * 2) = 230
# picture + (padding * 2) + (margin * 2)
@@ -511,7 +513,7 @@ class AlbumsGrid(Gtk.Overlay):
# Helper Methods
# =========================================================================
def create_widget(self, item):
def create_widget(self, item: 'AlbumsGrid.AlbumModel') -> Gtk.Box:
widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Cover art image
@@ -523,7 +525,7 @@ class AlbumsGrid(Gtk.Overlay):
)
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(
name=name,
label=text,
@@ -535,7 +537,8 @@ class AlbumsGrid(Gtk.Overlay):
# Header for the widget
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')
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)
# Download the cover art.
def on_artwork_downloaded(f):
def on_artwork_downloaded(f: CacheManager.Result):
artwork.set_from_file(f.result())
artwork.set_loading(False)
@@ -566,8 +569,8 @@ class AlbumsGrid(Gtk.Overlay):
def reflow_grids(
self,
force_reload_from_master=False,
selection_changed=False,
force_reload_from_master: bool = False,
selection_changed: bool = False,
):
# Determine where the cuttoff is between the top and bottom grids.
entries_before_fold = len(self.list_store)

View File

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

View File

@@ -1,17 +1,15 @@
from typing import List, Tuple, Union
import gi
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.server.api_objects import Artist, Child
from sublime.state_manager import ApplicationState
from sublime.ui import util
from sublime.ui.common import IconButton
from sublime.server.api_objects import Child, Artist
class BrowsePanel(Gtk.Overlay):
"""Defines the arist panel."""

View File

@@ -1,22 +1,16 @@
from typing import Union
from random import randint
from typing import Any, Optional, Union
import gi
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.server.api_objects import AlbumWithSongsID3, Child, Directory
from sublime.state_manager import ApplicationState
from sublime.ui import util
from .icon_button import IconButton
from .spinner_image import SpinnerImage
from sublime.server.api_objects import (
AlbumWithSongsID3,
Child,
Directory,
)
from sublime.ui.common.icon_button import IconButton
from sublime.ui.common.spinner_image import SpinnerImage
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)
self.album = album
@@ -51,7 +50,7 @@ class AlbumWithSongs(Gtk.Box):
box.pack_start(Gtk.Box(), True, True, 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_loading(False)
@@ -125,7 +124,13 @@ class AlbumWithSongs(Gtk.Box):
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(
xalign=align,
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
@@ -177,11 +182,11 @@ class AlbumWithSongs(Gtk.Box):
# Event Handlers
# =========================================================================
def on_song_selection_change(self, event):
def on_song_selection_change(self, event: Any):
if not self.album_songs.has_focus():
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.
self.emit(
'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
clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path:
@@ -199,7 +204,7 @@ class AlbumWithSongs(Gtk.Box):
store, paths = tree.get_selection().get_selected_rows()
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)
# Use the new selection instead of the old one for calculating what
@@ -228,14 +233,16 @@ class AlbumWithSongs(Gtk.Box):
if not allow_deselect:
return True
def on_download_all_click(self, btn):
return False
def on_download_all_click(self, btn: Any):
CacheManager.batch_download_songs(
[x[-1] for x in self.album_song_store],
before_download=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]
self.emit(
'song-clicked',
@@ -244,7 +251,7 @@ class AlbumWithSongs(Gtk.Box):
{'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]
self.emit(
'song-clicked',
@@ -259,10 +266,10 @@ class AlbumWithSongs(Gtk.Box):
def deselect_all(self):
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)
def set_loading(self, loading):
def set_loading(self, loading: bool):
if loading:
self.loading_indicator.start()
self.loading_indicator.show()
@@ -279,8 +286,8 @@ class AlbumWithSongs(Gtk.Box):
self,
album: Union[AlbumWithSongsID3, Child, Directory],
state: ApplicationState,
force=False,
order_token=None,
force: bool = False,
order_token: Optional[int] = None,
):
new_store = [
[

View File

@@ -1,4 +1,4 @@
from typing import List, Tuple, Optional
from typing import Any, List, Optional, Tuple
import gi
gi.require_version('Gtk', '3.0')
@@ -17,7 +17,7 @@ class EditFormDialog(Gtk.Dialog):
extra_label: Optional[str] = None
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.
"""
@@ -26,7 +26,7 @@ class EditFormDialog(Gtk.Dialog):
def get_default_object(self):
return None
def __init__(self, parent, existing_object=None):
def __init__(self, parent: Any, existing_object: Any = None):
editing = existing_object is not None
title = getattr(self, 'title', lambda: None)
if not title:

View File

@@ -1,3 +1,5 @@
from typing import Optional
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
@@ -5,12 +7,12 @@ from gi.repository import Gtk
class IconButton(Gtk.Button):
def __init__(
self,
icon_name,
relief=False,
icon_size=Gtk.IconSize.BUTTON,
label=None,
**kwargs,
self,
icon_name: Optional[str],
relief: bool = False,
icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
label: str = None,
**kwargs,
):
Gtk.Button.__init__(self, **kwargs)
self.icon_size = icon_size
@@ -29,5 +31,5 @@ class IconButton(Gtk.Button):
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)

View File

@@ -1,16 +1,18 @@
from typing import Optional
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GdkPixbuf
from gi.repository import GdkPixbuf, Gtk
class SpinnerImage(Gtk.Overlay):
def __init__(
self,
loading=True,
image_name=None,
spinner_name=None,
image_size=None,
**kwargs,
self,
loading: bool = True,
image_name: str = None,
spinner_name: str = None,
image_size: int = None,
**kwargs,
):
Gtk.Overlay.__init__(self)
self.image_size = image_size
@@ -26,7 +28,7 @@ class SpinnerImage(Gtk.Overlay):
)
self.add_overlay(self.spinner)
def set_from_file(self, filename):
def set_from_file(self, filename: Optional[str]):
if filename == '':
filename = None
if self.image_size is not None and filename:
@@ -40,7 +42,7 @@ class SpinnerImage(Gtk.Overlay):
else:
self.image.set_from_file(filename)
def set_loading(self, loading_status):
def set_loading(self, loading_status: bool):
if loading_status:
self.spinner.start()
self.spinner.show()

View File

@@ -1,11 +1,12 @@
import subprocess
from typing import Any
import gi
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.config import ServerConfiguration
from sublime.ui.common import EditFormDialog, IconButton
@@ -36,7 +37,7 @@ class EditServerDialog(EditFormDialog):
super().__init__(*args, **kwargs)
def on_test_server_clicked(self, event):
def on_test_server_clicked(self, event: Any):
# Instantiate the server.
server_address = self.data['server_address'].get_text()
server = Server(
@@ -72,7 +73,7 @@ class EditServerDialog(EditFormDialog):
dialog.run()
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()])
@@ -84,7 +85,7 @@ class ConfigureServersDialog(Gtk.Dialog):
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object, )),
}
def __init__(self, parent, config):
def __init__(self, parent: Any, config: AppConfiguration):
Gtk.Dialog.__init__(
self,
title='Configure Servers',
@@ -116,13 +117,13 @@ class ConfigureServersDialog(Gtk.Dialog):
'document-edit-symbolic',
label='Edit...',
relief=True,
), lambda e: self.on_edit_clicked(e, False), 'start', True),
), lambda e: self.on_edit_clicked(False), 'start', True),
(
IconButton(
'list-add-symbolic',
label='Add...',
relief=True,
), lambda e: self.on_edit_clicked(e, True), 'start', False),
), lambda e: self.on_edit_clicked(True), 'start', False),
(
IconButton(
'list-remove-symbolic',
@@ -191,14 +192,14 @@ class ConfigureServersDialog(Gtk.Dialog):
self.server_list.select_row(
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()
if selected:
del self.server_configs[selected.get_index()]
self.refresh_server_list()
self.emit('server-list-changed', self.server_configs)
def on_edit_clicked(self, event, add):
def on_edit_clicked(self, add: bool):
if add:
dialog = EditServerDialog(self)
else:
@@ -236,12 +237,12 @@ class ConfigureServersDialog(Gtk.Dialog):
def on_server_list_activate(self, *args):
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()
self.emit('connected-server-changed', selected_index)
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
# selected in the server list.
has_selection = self.server_list.get_selected_row()

View File

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

View File

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

View File

@@ -1,16 +1,15 @@
from functools import lru_cache
from random import randint
from typing import List
from fuzzywuzzy import process
from typing import Any, Iterable, List, Optional, Tuple
import gi
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.state_manager import ApplicationState
from sublime.cache_manager import CacheManager
from sublime.ui import util
from sublime.ui.common import EditFormDialog, IconButton, SpinnerImage
@@ -21,7 +20,7 @@ class EditPlaylistDialog(EditFormDialog):
text_fields = [('Name', 'name', False), ('Comment', 'comment', False)]
boolean_fields = [('Public', 'public')]
def __init__(self, *args, playlist_id=None, **kwargs):
def __init__(self, *args, **kwargs):
delete_playlist = Gtk.Button(label='Delete Playlist')
self.extra_buttons = [(delete_playlist, Gtk.ResponseType.NO)]
super().__init__(*args, **kwargs)
@@ -59,7 +58,7 @@ class PlaylistsPanel(Gtk.Paned):
)
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_detail_panel.update(state=state, force=force)
@@ -77,7 +76,7 @@ class PlaylistList(Gtk.Box):
playlist_id = 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)
self.playlist_id = playlist_id
self.name = name
@@ -144,7 +143,8 @@ class PlaylistList(Gtk.Box):
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(
action_name='app.go-to-playlist',
action_target=GLib.Variant('s', model.playlist_id),
@@ -180,8 +180,8 @@ class PlaylistList(Gtk.Box):
self,
playlists: List[PlaylistWithSongs],
state: ApplicationState,
force=False,
order_token=None,
force: bool = False,
order_token: Optional[int] = None,
):
new_store = []
selected_idx = None
@@ -203,25 +203,25 @@ class PlaylistList(Gtk.Box):
# 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.grab_focus()
self.new_playlist_row.show()
def on_list_refresh_click(self, button):
def on_list_refresh_click(self, _: Any):
self.update(force=True)
def new_entry_activate(self, entry):
def new_entry_activate(self, entry: Gtk.Entry):
self.create_playlist(entry.get_text())
def cancel_button_clicked(self, button):
def cancel_button_clicked(self, _: Any):
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())
def create_playlist(self, playlist_name):
def on_playlist_created(f):
def create_playlist(self, playlist_name: str):
def on_playlist_created(_: Any):
CacheManager.invalidate_playlists_cache()
self.update(force=True)
@@ -339,7 +339,13 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Playlist songs list
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(
xalign=align,
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
@@ -362,11 +368,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
)
@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)))
@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)
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
def update(self, state: ApplicationState, force=False):
def update(self, state: ApplicationState, force: bool = False):
if state.selected_playlist_id is None:
self.playlist_artwork.set_from_file(None)
self.playlist_indicator.set_markup('')
@@ -460,10 +466,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
)
def update_playlist_view(
self,
playlist,
playlist: PlaylistWithSongs,
state: ApplicationState = None,
force=False,
order_token=None,
force: bool = False,
order_token: Optional[int] = None,
):
if self.update_playlist_view_order_token != order_token:
return
@@ -483,7 +489,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_comment.show()
else:
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.
self.update_playlist_artwork(
@@ -522,10 +528,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
)
def update_playlist_artwork(
self,
cover_art_filename,
cover_art_filename: str,
state: ApplicationState,
force=False,
order_token=None,
force: bool = False,
order_token: Optional[int] = None,
):
if self.update_playlist_view_order_token != order_token:
return
@@ -535,18 +541,17 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Event Handlers
# =========================================================================
def on_view_refresh_click(self, button):
def on_view_refresh_click(self, _: Any):
self.update_playlist_view(
self.playlist_id,
force=True,
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(
self.get_toplevel(),
CacheManager.get_playlist(self.playlist_id).result(),
playlist_id=self.playlist_id,
)
result = dialog.run()
@@ -577,7 +582,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
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):
GLib.idle_add(
lambda: self.update_playlist_view(
@@ -592,7 +597,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
on_song_download_complete=download_state_change,
)
def on_play_all_clicked(self, btn):
def on_play_all_clicked(self, _: Any):
self.emit(
'song-clicked',
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(
'song-clicked',
randint(0,
@@ -635,7 +640,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False
def on_download_state_change(song_id=None):
def on_download_state_change(**kwargs):
GLib.idle_add(
lambda: self.update_playlist_view(
self.playlist_id,
@@ -656,7 +661,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
def on_remove_songs_click(button):
def on_remove_songs_click(_: Any):
CacheManager.update_playlist(
playlist_id=self.playlist_id,
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
# reordering_playlist_song_list flag.
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
else:
self.reordering_playlist_song_list = True
@@ -705,7 +710,12 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_artwork.set_loading(True)
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(
label=text,
name=name,
@@ -714,7 +724,12 @@ class PlaylistDetailPanel(Gtk.Overlay):
)
@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()
update_playlist_future = CacheManager.update_playlist(
playlist_id=playlist.id,
@@ -730,7 +745,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
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')
lines = [
util.dot_join(

View File

@@ -1,19 +1,18 @@
import functools
from typing import Callable, List, Tuple, Any
import re
from concurrent.futures import Future
from deepdiff import DeepDiff
from typing import Any, Callable, cast, List, Match, Optional, Tuple, Union
import gi
from deepdiff import DeepDiff
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.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
zero-padded if necessary.
@@ -26,7 +25,11 @@ def format_song_duration(duration_secs) -> str:
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.
@@ -42,7 +45,7 @@ def pluralize(string: str, number: int, pluralized_form=None):
return string
def format_sequence_duration(duration_secs) -> str:
def format_sequence_duration(duration_secs: int) -> str:
"""
Formats duration in English.
@@ -76,20 +79,20 @@ def format_sequence_duration(duration_secs) -> str:
return ', '.join(format_components)
def esc(string):
def esc(string: str) -> str:
if string is None:
return None
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.
"""
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 = {
SongCacheStatus.NOT_CACHED: '',
SongCacheStatus.CACHED: 'folder-download-symbolic',
@@ -99,9 +102,9 @@ def get_cached_status_icon(cache_status: SongCacheStatus):
return cache_icon[cache_status]
def _parse_diff_location(location):
def _parse_diff_location(location: str):
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):
@@ -302,9 +305,9 @@ def show_song_popover(
def async_callback(
future_fn,
before_download=None,
on_failure=None,
future_fn: Callable[..., Future],
before_download: Callable[[Any], None] = None,
on_failure: Callable[[Any, Exception], None] = None,
):
"""
Defines the ``async_callback`` decorator.
@@ -315,16 +318,16 @@ def async_callback(
by said lambda function.
:param future_fn: a function which generates a
``concurrent.futures.Future``.
:class:`concurrent.futures.Future` or :class:`CacheManager.Result`.
"""
def decorator(callback_fn):
@functools.wraps(callback_fn)
def wrapper(
self,
*args,
state=None,
order_token=None,
force=False,
state: ApplicationState = None,
force: bool = False,
order_token: Optional[int] = None,
**kwargs,
):
if before_download:
@@ -333,15 +336,15 @@ def async_callback(
else:
on_before_download = (lambda: None)
def future_callback(f):
def future_callback(f: Union[Future, CacheManager.Result]):
try:
result = f.result()
except Exception as e:
if on_failure:
on_failure(self, e)
GLib.idle_add(on_failure, self, e)
return
return GLib.idle_add(
GLib.idle_add(
lambda: callback_fn(
self,
result,
@@ -350,7 +353,7 @@ def async_callback(
order_token=order_token,
))
future: Future = future_fn(
future: Union[Future, CacheManager.Result] = future_fn(
*args,
before_download=on_before_download,
force=force,