commit d58b5b94d34cabae2c1a3743d837f5cab75cc9c1 Author: Frederick Gnodtke Date: Sun Sep 18 04:33:46 2016 +0200 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fca6bdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +dist/ +*.conf +venv/ +*.egg-info diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6def079 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include MANIFEST.in +include README.md +include mopidy_subidy/ext.conf diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8db34f --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Mopidy Subidy + +A subsonic backend for mopidy. diff --git a/mopidy_subidy/__init__.py b/mopidy_subidy/__init__.py new file mode 100644 index 0000000..4120e57 --- /dev/null +++ b/mopidy_subidy/__init__.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + +import os + +from mopidy import ext, config + +__version__ = '0.0.0' + + +class SubidyExtension(ext.Extension): + + dist_name = 'Mopidy-Subidy' + ext_name = 'subidy' + version = __version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(SubidyExtension, self).get_config_schema() + schema['url'] = config.String() + schema['username'] = config.String() + schema['password'] = config.Secret() + return schema + + def setup(self, registry): + from .backend import SubidyBackend + registry.add('backend', SubidyBackend) diff --git a/mopidy_subidy/__init__.pyc b/mopidy_subidy/__init__.pyc new file mode 100644 index 0000000..0127199 Binary files /dev/null and b/mopidy_subidy/__init__.pyc differ diff --git a/mopidy_subidy/backend.py b/mopidy_subidy/backend.py new file mode 100644 index 0000000..7f39d98 --- /dev/null +++ b/mopidy_subidy/backend.py @@ -0,0 +1,16 @@ +from mopidy_subidy import library, playback, playlists, subsonic_api +from mopidy import backend +import pykka + +class SubidyBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): + super(SubidyBackend, self).__init__() + subidy_config = config['subidy'] + self.subsonic_api = subsonic_api.SubsonicApi( + url=subidy_config['url'], + username=subidy_config['username'], + password=subidy_config['password']) + self.library = library.SubidyLibraryProvider(backend=self) + self.playback = playback.SubidyPlaybackProvider(audio=audio, backend=self) + #self.playlists = playlists.SubidyPlaylistsProvider(backend=self) + self.uri_schemes = ['subidy'] diff --git a/mopidy_subidy/backend.pyc b/mopidy_subidy/backend.pyc new file mode 100644 index 0000000..6463ca2 Binary files /dev/null and b/mopidy_subidy/backend.pyc differ diff --git a/mopidy_subidy/ext.conf b/mopidy_subidy/ext.conf new file mode 100644 index 0000000..07f3f97 --- /dev/null +++ b/mopidy_subidy/ext.conf @@ -0,0 +1,6 @@ +[subidy] +enabled = true +url = +username = +password = + diff --git a/mopidy_subidy/library.py b/mopidy_subidy/library.py new file mode 100644 index 0000000..4abbf17 --- /dev/null +++ b/mopidy_subidy/library.py @@ -0,0 +1,57 @@ +from mopidy import backend, models +from mopidy_subidy import uri + +class SubidyLibraryProvider(backend.LibraryProvider): + root_directory = models.Ref(uri=uri.ROOT_URI, type=models.Ref.DIRECTORY, name='Subsonic') + + def __init__(self, *args, **kwargs): + super(SubidyLibraryProvider, self).__init__(*args, **kwargs) + self.subsonic_api = self.backend.subsonic_api + + def browse_songs(self,album_id): + return self.subsonic_api.get_songs_as_refs(album_id) + + def browse_albums(self, artist_id): + return self.subsonic_api.get_albums_as_refs(artist_id) + + def browse_artists(self): + return self.subsonic_api.get_artists_as_refs() + + def lookup_song(self, song_id): + return self.subsonic_api.find_song_by_id(song_id) + + def lookup_album(self, album_id): + return self.subsonic_api.find_album_by_id(album_id) + + def lookup_artist(self, artist_id): + return self.subsonic_api.find_artist_by_id(artist_id) + + def browse(self, browse_uri): + type = uri.get_type(browse_uri) + if browse_uri == uri.ROOT_URI: + return self.browse_artists() + if type == uri.ARTIST: + return self.browse_albums(uri.get_artist_id(browse_uri)) + if type == uri.ALBUM: + return self.browse_songs(uri.get_album_id(browse_uri)) + + def lookup_one(self, lookup_uri): + type = uri.get_type(lookup_uri) + if type == uri.ARTIST: + return self.lookup_artist(uri.get_artist_id(lookup_uri)) + if type == uri.ALBUM: + return self.lookup_album(uri.get_album_id(lookup_uri)) + if type == uri.SONG: + return self.lookup_song(uri.get_song_id(lookup_uri)) + + def lookup(self, uri=None, uris=None): + if uris is not None: + return [self.lookup_one(uri) for uri in uris] + if uri is not None: + return [self.lookup_one(uri)] + return None + + def refresh(self, uri): + pass + def search(self, query=None, uris=None, exact=False): + pass diff --git a/mopidy_subidy/library.pyc b/mopidy_subidy/library.pyc new file mode 100644 index 0000000..a504e2b Binary files /dev/null and b/mopidy_subidy/library.pyc differ diff --git a/mopidy_subidy/playback.py b/mopidy_subidy/playback.py new file mode 100644 index 0000000..3f29f6c --- /dev/null +++ b/mopidy_subidy/playback.py @@ -0,0 +1,10 @@ +from mopidy import backend +from mopidy_subidy import uri + +class SubidyPlaybackProvider(backend.PlaybackProvider): + def __init__(self, *args, **kwargs): + super(SubidyPlaybackProvider, self).__init__(*args, **kwargs) + self.subsonic_api = self.backend.subsonic_api + + def translate_uri(self, translate_uri): + return self.subsonic_api.get_song_stream_uri(uri.get_song_id(translate_uri)) diff --git a/mopidy_subidy/playback.pyc b/mopidy_subidy/playback.pyc new file mode 100644 index 0000000..73614b5 Binary files /dev/null and b/mopidy_subidy/playback.pyc differ diff --git a/mopidy_subidy/playlists.py b/mopidy_subidy/playlists.py new file mode 100644 index 0000000..f5adf8a --- /dev/null +++ b/mopidy_subidy/playlists.py @@ -0,0 +1,27 @@ +from mopidy import backend + +class SubidyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, *args, **kwargs): + super(SubidyPlaylistsProvider, self).__init__(*args, **kwargs) + self.playlists = [] + + def as_list(self): + pass + + def create(self, name): + pass + + def delete(self, uri): + pass + + def get_items(self, uri): + pass + + def lookup(self, uri): + pass + + def refresh(self): + pass + + def save(self, playlist): + pass diff --git a/mopidy_subidy/playlists.pyc b/mopidy_subidy/playlists.pyc new file mode 100644 index 0000000..357bb12 Binary files /dev/null and b/mopidy_subidy/playlists.pyc differ diff --git a/mopidy_subidy/subsonic_api.py b/mopidy_subidy/subsonic_api.py new file mode 100644 index 0000000..e3fe8ba --- /dev/null +++ b/mopidy_subidy/subsonic_api.py @@ -0,0 +1,179 @@ +from urlparse import urlparse +import libsonic +import logging +import itertools +from mopidy.models import Track, Album, Artist, Playlist, Ref +from mopidy_subidy import uri + +logger = logging.getLogger(__name__) + +RESPONSE_OK = u'ok' +UNKNOWN_SONG = u'Unknown Song' +UNKNOWN_ALBUM = u'Unknown Album' +UNKNOWN_ARTIST = u'Unknown Artist' +MAX_SEARCH_RESULTS = 100 + +ref_sort_key = lambda ref: ref.name + +class SubsonicApi(): + def __init__(self, url, username, password): + parsed = urlparse(url) + self.port = parsed.port if parsed.port else \ + 443 if parsed.scheme == 'https' else 80 + base_url = parsed.scheme + '://' + parsed.hostname + self.connection = libsonic.Connection( + base_url, + username, + password, + self.port, + parsed.path + '/rest') + self.url = url + '/rest' + self.username = username + self.password = password + logger.info('Connecting to subsonic server on url %s as user %s' % (url, username)) + try: + self.connection.ping() + except Exception as e: + logger.error('Unabled to reach subsonic server: %s' % e) + exit() + + def get_song_stream_uri(self, song_id): + template = '%s/stream.view?id=%s&u=%s&p=%s&c=mopidy&v=1.14' + return template % (self.url, song_id, self.username, self.password) + + def find_artists_by_name(self, artist_name): + response = self.connection.search3(artist_name, MAX_SEARCH_RESULTS, 0, 0, 0, 0, 0) + if response.get('status') != RESPONSE_OK: + return None + artists = response.get('searchResult3').get('artist') + if artists is not None: + return [self.raw_artist_to_artist(artist) for artist in artists] + return None + + def find_tracks_by_name(self, track_name): + response = self.connection.search3(track_name, 0, 0, 0, 0, MAX_SEARCH_RESULTS, 0) + if response.get('status') != RESPONSE_OK: + return None + tracks = response.get('searchResult3').get('song') + if tracks is not None: + return [self.raw_song_to_track(track) for track in tracks] + return None + + def find_albums_by_name(self, album_name): + response = self.connection.search3(album_name, 0, 0, MAX_SEARCH_RESULTS, 0, 0, 0) + if response.get('status') != RESPONSE_OK: + return None + albums = response.get('searchResult3').get('album') + if albums is not None: + return [self.raw_album_to_album(album) for album in albums] + return None + + def get_raw_artists(self): + response = self.connection.getIndexes() + if response.get('status') != RESPONSE_OK: + return None + letters = response.get('indexes').get('index') + if letters is not None: + return [artist for letter in letters for artist in letter.get('artist')] + return None + + def find_song_by_id(self, song_id): + response = self.connection.getSong(song_id) + if response.get('status') != RESPONSE_OK: + return None + return self.raw_song_to_track(response.get('song')) if response.get('song') is not None else None + + def find_album_by_id(self, album_id): + response = self.connection.getAlbum(album_id) + if response.get('status') != RESPONSE_OK: + return None + return self.raw_album_to_album(response.get('album')) if response.get('album') is not None else None + + def find_artist_by_id(self, artist_id): + response = self.connection.getArtist(artist_id) + if response.get('status') != RESPONSE_OK: + return None + return self.raw_artist_to_artist(response.get('artist')) if response.get('artist') is not None else None + + def get_raw_dir(self, parent_id): + response = self.connection.getMusicDirectory(parent_id) + if response.get('status') != RESPONSE_OK: + return None + directory = response.get('directory') + if directory is not None: + return directory.get('child') + return None + + def get_raw_albums(self, artist_id): + return self.get_raw_dir(artist_id) + + def get_raw_songs(self, album_id): + return self.get_raw_dir(album_id) + + def get_albums_as_refs(self, artist_id): + return sorted([self.raw_album_to_ref(album) for album in self.get_raw_albums(artist_id)], key=ref_sort_key) + + def get_albums_as_albums(self, artist_id): + return sorted([self.raw_album_to_album(album) for album in self.get_raw_albums(artist_id)], key=ref_sort_key) + + def get_songs_as_refs(self, album_id): + return sorted([self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)], key=ref_sort_key) + + def get_songs_as_tracks(self, album_id): + return sorted([self.raw_song_to_track(song) for song in self.get_raw_songs(album_id)], key=ref_sort_key) + + def get_artists_as_refs(self): + return sorted([self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()], key=ref_sort_key) + + def get_artists_as_artists(self): + return sorted([self.raw_artist_to_artist(artist) for artist in self.get_raw_artists()], key=lambda artist:artist.name) + + def raw_song_to_ref(self, song): + return Ref( + name=song.get('title') or UNKNOWN_SONG, + uri=uri.get_song_uri(song.get('id')), + type=Ref.TRACK) + + def raw_song_to_track(self, song): + album_name = song.get('album') + album = self.find_albums_by_name(album_name)[0] if album_name is not None else None + artist_name = song.get('artist') + artist = self.find_artists_by_name(artist_name)[0] if artist_name is not None else None + return Track( + name=song.get('title') or UNKNOWN_SONG, + uri=uri.get_song_uri(song.get('id')), + bitrate=song.get('bitRate'), + track_no=int(song.get('track')) if song.get('track') else None, + date=str(song.get('year')) or 'none', + genre=song.get('genre'), + length=int(song.get('duration')) * 1000 if song.get('duration') else None, + disc_no=int(song.get('discNumber')) if song.get('discNumber') else None, + artists=[artist] if artist is not None else None, + album=album + ) + def raw_album_to_ref(self, album): + return Ref( + name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, + uri=uri.get_album_uri(album.get('id')), + type=Ref.ALBUM) + + def raw_album_to_album(self, album): + artist_name = album.get('artist') + artist = self.find_artists_by_name(artist_name)[0] if artist_name is not None else None + return Album( + name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, + uri=uri.get_album_uri(album.get('id')), + artists=[artist] + ) + + def raw_artist_to_ref(self, artist): + return Ref( + name=artist.get('name') or UNKNOWN_ARTIST, + uri=uri.get_artist_uri(artist.get('id')), + type=Ref.ARTIST) + + def raw_artist_to_artist(self, artist): + return Artist( + name=artist.get('name') or UNKNOWN_ARTIST, + uri=uri.get_artist_uri(artist.get('id')) + ) diff --git a/mopidy_subidy/subsonic_api.pyc b/mopidy_subidy/subsonic_api.pyc new file mode 100644 index 0000000..9c9cf2b Binary files /dev/null and b/mopidy_subidy/subsonic_api.pyc differ diff --git a/mopidy_subidy/uri.py b/mopidy_subidy/uri.py new file mode 100644 index 0000000..4b5a169 --- /dev/null +++ b/mopidy_subidy/uri.py @@ -0,0 +1,60 @@ +import re + +SONG = 'song' +ARTIST = 'artist' +PLAYLIST = 'playlist' +ALBUM = 'album' +PREFIX = 'subidy' +ROOT = 'root' + +ROOT_URI = '%s:%s' % (PREFIX, ROOT) + +regex = re.compile(r'(\w+?):(\w+?)(?::|$)(.+?)?$') + +def is_type_result_valid(result): + return result is not None and result.group(1) == PREFIX + +def is_id_result_valid(result, type): + return is_type_result_valid(result) and result.group(1) == PREFIX and result.group(2) == type + +def get_song_id(uri): + result = regex.match(uri) + if not is_id_result_valid(result, SONG): + return None + return result.group(3) + +def get_artist_id(uri): + result = regex.match(uri) + if not is_id_result_valid(result, ARTIST): + return None + return result.group(3) + +def get_playlist_id(uri): + result = regex.match(uri) + if not is_id_result_valid(result, PLAYLIST): + return None + return result.group(3) + +def get_album_id(uri): + result = regex.match(uri) + if not is_id_result_valid(result, ALBUM): + return None + return result.group(3) + +def get_type(uri): + result = regex.match(uri) + if not is_type_result_valid(result): + return None + return result.group(2) + +def get_type_uri(type, id): + return u'%s:%s:%s' % (PREFIX, type, id) + +def get_artist_uri(id): + return get_type_uri(ARTIST, id) + +def get_album_uri(id): + return get_type_uri(ALBUM, id) + +def get_song_uri(id): + return get_type_uri(SONG, id) diff --git a/mopidy_subidy/uri.pyc b/mopidy_subidy/uri.pyc new file mode 100644 index 0000000..02b8d01 Binary files /dev/null and b/mopidy_subidy/uri.pyc differ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f7c71f7 --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +from __future__ import unicode_literals + +import re +from setuptools import setup, find_packages + + +def get_version(filename): + content = open(filename).read() + metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content)) + return metadata['version'] + +setup( + name='Mopidy-Subidy', + version=get_version('mopidy_subidy/__init__.py'), + url='http://github.com/prior99/mopidy-subidy/', + license='MIT', + author='ptiot99', + author_email='fgnodtke@cronosx.de', + description='Improved Subsonic extension for Mopidy', + long_description=open('README.md').read(), + packages=find_packages(exclude=['tests', 'tests.*']), + zip_safe=False, + include_package_data=True, + install_requires=[ + 'setuptools', + 'Mopidy >= 2.0', + 'py-sonic', + 'Pykka >= 1.1' + ], + entry_points={ + b'mopidy.ext': [ + 'subidy = mopidy_subidy:SubidyExtension', + ], + }, + classifiers=[ + 'Environment :: No Input/Output (Daemon)', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Topic :: Multimedia :: Sound/Audio :: Players' + ] +)