From 2036830ed338477c7b11fa325ece4c20d99fd889 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 21 Jun 2019 23:09:38 -0600 Subject: [PATCH] Generated API objects --- api_object_generator/api_object_generator.py | 49 +- libremsonic/__main__.py | 1 + libremsonic/from_json.py | 10 +- libremsonic/server/api_object.py | 5 +- libremsonic/server/api_objects.py | 581 ++++++++++++++----- libremsonic/server/server.py | 19 +- 6 files changed, 496 insertions(+), 169 deletions(-) diff --git a/api_object_generator/api_object_generator.py b/api_object_generator/api_object_generator.py index d862277..8c8d603 100755 --- a/api_object_generator/api_object_generator.py +++ b/api_object_generator/api_object_generator.py @@ -7,7 +7,7 @@ represents those API objects in Python. import re from collections import defaultdict -from typing import cast, Dict, DefaultDict, Set, Match, Tuple, List, Union +from typing import cast, Dict, DefaultDict, Set, Match, Tuple, List import sys from graphviz import Digraph @@ -16,6 +16,13 @@ from lxml import etree # Global variables. tag_type_re = re.compile(r'\{.*\}(.*)') element_type_re = re.compile(r'.*:(.*)') +primitive_translation_map = { + 'string': 'str', + 'double': 'float', + 'boolean': 'bool', + 'long': 'int', + 'dateTime': 'datetime', +} def render_digraph(graph, filename): @@ -32,8 +39,14 @@ def render_digraph(graph, filename): g.render() +def primitive_translate(type_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 cast(Match, element_type_re.match(type_str)).group(1) + return primitive_translate( + cast(Match, element_type_re.match(type_str)).group(1)) def extract_tag_type(tag_type_str): @@ -71,15 +84,15 @@ def get_dependencies(xs_el) -> Tuple[Set[str], Dict[str, str]]: restriction = xs_el.getchildren()[0] restriction_type = extract_type(restriction.attrib['base']) - if restriction_type == 'string': + if restriction_type == 'str': restriction_children = restriction.getchildren() if extract_tag_type(restriction_children[0].tag) == 'enumeration': type_fields['__inherits__'] = 'Enum' for rc in restriction_children: - rc_type = rc.attrib['value'] + rc_type = primitive_translate(rc.attrib['value']) type_fields[rc_type] = rc_type else: - type_fields['__inherits__'] = 'string' + type_fields['__inherits__'] = 'str' else: type_fields['__inherits__'] = restriction_type @@ -139,7 +152,7 @@ def get_dependencies(xs_el) -> Tuple[Set[str], Dict[str, str]]: else: raise Exception(f'Unknown tag type {tag_type}.') - depends_on -= {'boolean', 'int', 'string', 'float', 'long', 'dateTime'} + depends_on -= {'bool', 'int', 'str', 'float', 'datetime'} return depends_on, type_fields @@ -151,7 +164,7 @@ if len(sys.argv) < 3: schema_file, output_file = sys.argv[1:] -# First pass, determine who depends on what. +# Determine who depends on what and determine what fields are on each object. # ============================================================================= with open(schema_file) as f: tree = etree.parse(f) @@ -204,22 +217,26 @@ dfs(dependency_graph, 'subsonic-response') output_order = [x[0] for x in sorted(end_times, key=lambda x: x[1])] output_order.remove('subsonic-response') -# Second pass, determine the fields on each of the elements and create the code -# accordingly. +# Create the code according to the spec that was generated earlier. # ============================================================================= def generate_class_for_type(type_name): - # print(type_name, type_fields[type_name]) fields = type_fields[type_name] - is_enum = 'Enum' in fields.get('__inherits__', '') code = ['', ''] inherits_from = ['APIObject'] - for inherit in map(str.strip, fields.get('__inherits__', '').split(',')): - if inherit != '': - inherits_from.append(inherit) + inherits = fields.get('__inherits__', '') + is_enum = 'Enum' in inherits + + if inherits: + if inherits in primitive_translation_map.values() or is_enum: + inherits_from.append(inherits) + else: + # Add the fields, we can't directly inherit due to the Diamond + # Problem. + fields.update(type_fields[inherits]) format_str = ' ' + ("{} = '{}'" if is_enum else '{}: {}') @@ -229,7 +246,9 @@ def generate_class_for_type(type_name): if key.startswith('__'): continue + # Uppercase the key if an Enum. key = key.upper() if is_enum else key + code.append(format_str.format(key, value)) has_properties = True @@ -251,6 +270,6 @@ with open(output_file, 'w+') as outfile: 'from datetime import datetime', 'from typing import List', 'from enum import Enum', - 'from .api_object import APIObject', + 'from libremsonic.server.api_object import APIObject', *map(generate_class_for_type, output_order), ]) + '\n') diff --git a/libremsonic/__main__.py b/libremsonic/__main__.py index d8582e6..a6220ce 100644 --- a/libremsonic/__main__.py +++ b/libremsonic/__main__.py @@ -13,6 +13,7 @@ def main(): server = Server('ohea', 'https://airsonic.the-evans.family', 'sumner', 'O}/UieSb[nzZ~l[X1S&zzX1Hi') + print(server.ping()) print(server.get_license()) # app = LibremsonicApp() # app.run(sys.argv) diff --git a/libremsonic/from_json.py b/libremsonic/from_json.py index ddbcb89..b9ed264 100644 --- a/libremsonic/from_json.py +++ b/libremsonic/from_json.py @@ -25,19 +25,17 @@ def from_json(cls, data): annotations: Dict[str, Type] = getattr(cls, '__annotations__', {}) - print(type(cls)) - # Handle primitive of objects if data is None: instance = None - elif cls == str: + elif cls == str or issubclass(cls, str): instance = data - elif cls == int: + elif cls == int or issubclass(cls, int): instance = int(data) - elif cls == bool: + elif cls == bool or issubclass(cls, bool): instance = bool(data) elif type(cls) == EnumMeta: - instance = cls[data] + instance = cls(data) elif cls == datetime: instance = parser.parse(data) diff --git a/libremsonic/server/api_object.py b/libremsonic/server/api_object.py index 36f65d8..6e7b09f 100644 --- a/libremsonic/server/api_object.py +++ b/libremsonic/server/api_object.py @@ -19,11 +19,14 @@ class APIObject: def __repr__(self): if isinstance(self, Enum): return super().__repr__() + if isinstance(self, str): + return self annotations: Dict[str, Any] = self.get('__annotations__', {}) typename = type(self).__name__ fieldstr = ' '.join([ f'{field}={getattr(self, field)!r}' - for field in annotations.keys() if hasattr(self, field) + for field in annotations.keys() + if hasattr(self, field) and getattr(self, field) is not None ]) return f'<{typename} {fieldstr}>' diff --git a/libremsonic/server/api_objects.py b/libremsonic/server/api_objects.py index e5dd0e4..f018771 100644 --- a/libremsonic/server/api_objects.py +++ b/libremsonic/server/api_objects.py @@ -1,31 +1,37 @@ +""" +WARNING: AUTOGENERATED FILE +This file was generated by the api_object_generator.py script. Do +not modify this file directly, rather modify the script or run it on +a new API version. +""" + from datetime import datetime from typing import List from enum import Enum - -from .api_object import APIObject +from libremsonic.server.api_object import APIObject -class SubsonicError(APIObject): - code: int - message: str - - def as_exception(self): - return Exception(f'{self.code}: {self.message}') +class AlbumInfo(APIObject): + notes: List[str] + musicBrainzId: List[str] + lastFmUrl: List[str] + smallImageUrl: List[str] + mediumImageUrl: List[str] + largeImageUrl: List[str] -class License(APIObject): - valid: bool - email: str - licenseExpires: datetime - trialExpires: datetime - - -class MusicFolder(APIObject): - id: int - name: str +class AverageRating(APIObject, float): + pass class MediaType(APIObject, Enum): + MUSIC = 'music' + PODCAST = 'podcast' + AUDIOBOOK = 'audiobook' + VIDEO = 'video' + + +class UserRating(APIObject, int): pass @@ -63,19 +69,8 @@ class Child(APIObject): originalHeight: int -class Album(APIObject): - id: int - name: str - artist: str - artistId: int - coverArt: str - songCount: int - duration: int - created: datetime - year: str - genre: str - - song: List[Child] +class AlbumList(APIObject): + album: List[Child] class AlbumID3(APIObject): @@ -93,7 +88,12 @@ class AlbumID3(APIObject): genre: str +class AlbumList2(APIObject): + album: List[AlbumID3] + + class AlbumWithSongsID3(APIObject): + song: List[Child] id: str name: str artist: str @@ -107,8 +107,6 @@ class AlbumWithSongsID3(APIObject): year: int genre: str - song: List[Child] - class Artist(APIObject): id: str @@ -119,6 +117,25 @@ class Artist(APIObject): averageRating: AverageRating +class ArtistInfoBase(APIObject): + biography: List[str] + musicBrainzId: List[str] + lastFmUrl: List[str] + smallImageUrl: List[str] + mediumImageUrl: List[str] + largeImageUrl: List[str] + + +class ArtistInfo(APIObject): + similarArtist: List[Artist] + biography: List[str] + musicBrainzId: List[str] + lastFmUrl: List[str] + smallImageUrl: List[str] + mediumImageUrl: List[str] + largeImageUrl: List[str] + + class ArtistID3(APIObject): id: str name: str @@ -128,35 +145,61 @@ class ArtistID3(APIObject): starred: datetime +class ArtistInfo2(APIObject): + similarArtist: List[ArtistID3] + biography: List[str] + musicBrainzId: List[str] + lastFmUrl: List[str] + smallImageUrl: List[str] + mediumImageUrl: List[str] + largeImageUrl: List[str] + + class ArtistWithAlbumsID3(APIObject): + album: List[AlbumID3] id: str name: str coverArt: str artistImageUrl: str albumCount: int starred: datetime - album: List[AlbumID3] - - -class Index(APIObject): - name: str - artist: List[Artist] class IndexID3(APIObject): - name: str artist: List[ArtistID3] + name: str -class Indexes(APIObject): - lastModified: int +class ArtistsID3(APIObject): + index: List[IndexID3] ignoredArticles: str - index: List[Index] - shortcut: List[Artist] - child: List[Child] + + +class Bookmark(APIObject): + entry: List[Child] + position: int + username: str + comment: str + created: datetime + changed: datetime + + +class Bookmarks(APIObject): + bookmark: List[Bookmark] + + +class ChatMessage(APIObject): + username: str + time: int + message: str + + +class ChatMessages(APIObject): + chatMessage: List[ChatMessage] class Directory(APIObject): + child: List[Child] id: str parent: str name: str @@ -165,7 +208,10 @@ class Directory(APIObject): averageRating: AverageRating playCount: int - child: List[Child] + +class Error(APIObject): + code: int + message: str class Genre(APIObject): @@ -173,28 +219,321 @@ class Genre(APIObject): albumCount: int -class ArtistInfo(APIObject): - biography: str - musicBrainzId: str - lastFmUrl: str - smallImageUrl: str - mediumImageUrl: str - largeImageUrl: str - similarArtist: List[Artist] +class Genres(APIObject): + genre: List[Genre] -class AlbumInfo(APIObject): - notes: str - musicBrainzId: str - lastFmUrl: str - smallImageUrl: str - mediumImageUrl: str - largeImageUrl: str +class Index(APIObject): + artist: List[Artist] + name: str -class Captions(APIObject): +class Indexes(APIObject): + shortcut: List[Artist] + index: List[Index] + child: List[Child] + lastModified: int + ignoredArticles: str + + +class InternetRadioStation(APIObject): id: str name: str + streamUrl: str + homePageUrl: str + + +class InternetRadioStations(APIObject): + internetRadioStation: List[InternetRadioStation] + + +class JukeboxStatus(APIObject): + currentIndex: int + playing: bool + gain: float + position: int + + +class JukeboxPlaylist(APIObject): + entry: List[Child] + currentIndex: int + playing: bool + gain: float + position: int + + +class License(APIObject): + valid: bool + email: str + licenseExpires: datetime + trialExpires: datetime + + +class Lyrics(APIObject): + artist: str + title: str + + +class MusicFolder(APIObject): + id: int + name: str + + +class MusicFolders(APIObject): + musicFolder: List[MusicFolder] + + +class PodcastStatus(APIObject, Enum): + NEW = 'new' + DOWNLOADING = 'downloading' + COMPLETED = 'completed' + ERROR = 'error' + DELETED = 'deleted' + SKIPPED = 'skipped' + + +class PodcastEpisode(APIObject): + streamId: str + channelId: str + description: str + status: PodcastStatus + publishDate: datetime + id: str + parent: str + isDir: bool + title: str + album: str + artist: str + track: int + year: int + genre: str + coverArt: str + size: int + contentType: str + suffix: str + transcodedContentType: str + transcodedSuffix: str + duration: int + bitRate: int + path: str + isVideo: bool + userRating: UserRating + averageRating: AverageRating + playCount: int + discNumber: int + created: datetime + starred: datetime + albumId: str + artistId: str + type: MediaType + bookmarkPosition: int + originalWidth: int + originalHeight: int + + +class NewestPodcasts(APIObject): + episode: List[PodcastEpisode] + + +class NowPlayingEntry(APIObject): + username: str + minutesAgo: int + playerId: int + playerName: str + id: str + parent: str + isDir: bool + title: str + album: str + artist: str + track: int + year: int + genre: str + coverArt: str + size: int + contentType: str + suffix: str + transcodedContentType: str + transcodedSuffix: str + duration: int + bitRate: int + path: str + isVideo: bool + userRating: UserRating + averageRating: AverageRating + playCount: int + discNumber: int + created: datetime + starred: datetime + albumId: str + artistId: str + type: MediaType + bookmarkPosition: int + originalWidth: int + originalHeight: int + + +class NowPlaying(APIObject): + entry: List[NowPlayingEntry] + + +class PlayQueue(APIObject): + entry: List[Child] + current: int + position: int + username: str + changed: datetime + changedBy: str + + +class Playlist(APIObject): + allowedUser: List[str] + id: str + name: str + comment: str + owner: str + public: bool + songCount: int + duration: int + created: datetime + changed: datetime + coverArt: str + + +class PlaylistWithSongs(APIObject): + entry: List[Child] + allowedUser: List[str] + id: str + name: str + comment: str + owner: str + public: bool + songCount: int + duration: int + created: datetime + changed: datetime + coverArt: str + + +class Playlists(APIObject): + playlist: List[Playlist] + + +class PodcastChannel(APIObject): + episode: List[PodcastEpisode] + id: str + url: str + title: str + description: str + coverArt: str + originalImageUrl: str + status: PodcastStatus + errorMessage: str + + +class Podcasts(APIObject): + channel: List[PodcastChannel] + + +class ResponseStatus(APIObject, Enum): + OK = 'ok' + FAILED = 'failed' + + +class ScanStatus(APIObject): + scanning: bool + count: int + + +class SearchResult(APIObject): + match: List[Child] + offset: int + totalHits: int + + +class SearchResult2(APIObject): + artist: List[Artist] + album: List[Child] + song: List[Child] + + +class SearchResult3(APIObject): + artist: List[ArtistID3] + album: List[AlbumID3] + song: List[Child] + + +class Share(APIObject): + entry: List[Child] + id: str + url: str + description: str + username: str + created: datetime + expires: datetime + lastVisited: datetime + visitCount: int + + +class Shares(APIObject): + share: List[Share] + + +class SimilarSongs(APIObject): + song: List[Child] + + +class SimilarSongs2(APIObject): + song: List[Child] + + +class Songs(APIObject): + song: List[Child] + + +class Starred(APIObject): + artist: List[Artist] + album: List[Child] + song: List[Child] + + +class Starred2(APIObject): + artist: List[ArtistID3] + album: List[AlbumID3] + song: List[Child] + + +class TopSongs(APIObject): + song: List[Child] + + +class User(APIObject): + folder: List[int] + username: str + email: str + scrobblingEnabled: bool + maxBitRate: int + adminRole: bool + settingsRole: bool + downloadRole: bool + uploadRole: bool + playlistRole: bool + coverArtRole: bool + commentRole: bool + podcastRole: bool + streamRole: bool + jukeboxRole: bool + shareRole: bool + videoConversionRole: bool + avatarLastChanged: datetime + + +class Users(APIObject): + user: List[User] + + +class Version(APIObject, str): + pass class AudioTrack(APIObject): @@ -203,6 +542,11 @@ class AudioTrack(APIObject): languageCode: str +class Captions(APIObject): + id: str + name: str + + class VideoConversion(APIObject): id: str bitRate: int @@ -210,96 +554,59 @@ class VideoConversion(APIObject): class VideoInfo(APIObject): - id: str captions: List[Captions] audioTrack: List[AudioTrack] conversion: List[VideoConversion] - - -class ArtistsID3(APIObject): - ignoredArticles: str - index: List[IndexID3] - - -class MusicFolders(APIObject): - musicFolder: List[MusicFolder] - - -class Genres(APIObject): - genre: List[Genre] - - -class Artists(APIObject): - index: List[Index] + id: str class Videos(APIObject): video: List[Child] -class SimilarSongs(APIObject): - song: List[Child] - - -class TopSongs(APIObject): - song: List[Child] - - -class AlbumList(APIObject): - album: List[Album] - - -class ResponseStatus(APIObject, Enum): - ok = "ok" - failed = "failed" - - -class SubsonicResponse(APIObject): - # On every Subsonic Response - status: ResponseStatus - version: str - - # One of these will exist on each SubsonicResponse - album: AlbumWithSongsID3 - albumInfo: AlbumInfo - albumList: AlbumList - albumList2: AlbumList2 - artist: ArtistWithAlbumsID3 - artistInfo: ArtistInfo - artistInfo2: ArtistInfo2 - artists: ArtistsID3 - bookmarks: Bookmarks - chatMessages: ChatMessages - directory: Directory - error: Error - genres: Genres - indexes: Indexes - internetRadioStations: InternetRadioStations - jukeboxPlaylist: JukeboxPlaylist - jukeboxStatus: JukeboxStatus - license: License - lyrics: Lyrics +class Response(APIObject): musicFolders: MusicFolders - newestPodcasts: NewestPodcasts + indexes: Indexes + directory: Directory + genres: Genres + artists: ArtistsID3 + artist: ArtistWithAlbumsID3 + album: AlbumWithSongsID3 + song: Child + videos: Videos + videoInfo: VideoInfo nowPlaying: NowPlaying - playlist: PlaylistWithSongs - playlists: Playlists - playQueue: PlayQueue - podcasts: Podcasts - randomSongs: Songs - scanStatus: ScanStatus searchResult: SearchResult searchResult2: SearchResult2 searchResult3: SearchResult3 - shares: Shares - similarSongs: SimilarSongs - similarSongs2: SimilarSongs2 - song: Child + playlists: Playlists + playlist: PlaylistWithSongs + jukeboxStatus: JukeboxStatus + jukeboxPlaylist: JukeboxPlaylist + license: License + users: Users + user: User + chatMessages: ChatMessages + albumList: AlbumList + albumList2: AlbumList2 + randomSongs: Songs songsByGenre: Songs + lyrics: Lyrics + podcasts: Podcasts + newestPodcasts: NewestPodcasts + internetRadioStations: InternetRadioStations + bookmarks: Bookmarks + playQueue: PlayQueue + shares: Shares starred: Starred starred2: Starred2 + albumInfo: AlbumInfo + artistInfo: ArtistInfo + artistInfo2: ArtistInfo2 + similarSongs: SimilarSongs + similarSongs2: SimilarSongs2 topSongs: TopSongs - user: User - users: Users - videos: Videos - videoInfo: VideoInfo + scanStatus: ScanStatus + error: Error + status: ResponseStatus + version: Version diff --git a/libremsonic/server/server.py b/libremsonic/server/server.py index ba3dbb5..568b706 100644 --- a/libremsonic/server/server.py +++ b/libremsonic/server/server.py @@ -1,9 +1,9 @@ import requests from typing import List, Optional, Dict -from .api_objects import (SubsonicResponse, License, MusicFolder, Indexes, - AlbumInfo, ArtistInfo, VideoInfo, Child, Album, - Artist, Artists, Directory, Genre) +from .api_objects import (Response, License, MusicFolder, Indexes, AlbumInfo, + ArtistInfo, VideoInfo, Child, AlbumID3, Artist, + ArtistsID3, Directory, Genre) class Server: @@ -29,12 +29,12 @@ class Server: def _make_url(self, endpoint: str) -> str: return f'{self.hostname}/rest/{endpoint}.view' - def _post(self, url, **params) -> SubsonicResponse: + def _post(self, url, **params) -> Response: """ Make a post to a *Sonic REST API. Handle all types of errors including *Sonic ```` responses. - :returns: a SubsonicResponse containing all of the data of the + :returns: a Response containing all of the data of the response, deserialized :raises Exception: needs some work TODO """ @@ -54,7 +54,7 @@ class Server: # TODO: logging print(subsonic_response) - response = SubsonicResponse.from_json(subsonic_response) + response = Response.from_json(subsonic_response) # Check for an error and if it exists, raise it. if response.get('error'): @@ -62,7 +62,7 @@ class Server: return response - def ping(self) -> SubsonicResponse: + def ping(self) -> Response: """ Used to test connectivity with the server. """ @@ -73,7 +73,6 @@ class Server: Get details about the software license. """ result = self._post(self._make_url('getLicense')) - print(result) return result.license def get_music_folders(self) -> List[MusicFolder]: @@ -119,7 +118,7 @@ class Server: result = self._post(self._make_url('getGenres')) return result.genres.genre - def get_artists(self, music_folder_id: int = None) -> Artists: + def get_artists(self, music_folder_id: int = None) -> ArtistsID3: """ Similar to getIndexes, but organizes music according to ID3 tags. @@ -140,7 +139,7 @@ class Server: result = self._post(self._make_url('getArtist'), id=artist_id) return result.artist - def get_album(self, album_id: int) -> Album: + def get_album(self, album_id: int) -> AlbumID3: """ Returns details for an album, including a list of songs. This method organizes music according to ID3 tags.