diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..bfd4689 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,51 @@ +version: 2.1 + +orbs: + codecov: codecov/codecov@1.0.5 + +workflows: + version: 2 + test: + jobs: + - py38 + - py37 + - black + - check-manifest + - flake8 + +jobs: + py38: &test-template + docker: + - image: mopidy/ci-python:3.8 + steps: + - checkout + - restore_cache: + name: Restoring tox cache + key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} + - run: + name: Run tests + command: | + tox -e $CIRCLE_JOB -- \ + --junit-xml=test-results/pytest/results.xml \ + --cov-report=xml + - save_cache: + name: Saving tox cache + key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} + paths: + - ./.tox + - ~/.cache/pip + - codecov/upload: + file: coverage.xml + - store_test_results: + path: test-results + + py37: + <<: *test-template + docker: + - image: mopidy/ci-python:3.7 + + black: *test-template + + check-manifest: *test-template + + flake8: *test-template diff --git a/.gitignore b/.gitignore index 4cd6c95..03640c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ -build/ -dist/ -*.conf -venv/ -*.egg-info *.pyc +/.coverage +/.mypy_cache/ +/.pytest_cache/ +/.tox/ +/*.egg-info +/build/ +/dist/ +/MANIFEST diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..9aceeaa --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,111 @@ +********* +Changelog +********* + + +v1.0.0 (2020-03-13) +=================== + +- Require Mopidy 3.0 or newer. + +- Update extension to match the Mopidy extension cookiecutter. + + +v0.4.1 (2020-02-01) +=================== + +- Require Python 3.7 or newer. + +- Require py-sonic 0.7.7 or newer. + + +v0.4.0 (2017-08-14) +=================== + +- Use Mopidy extension name as Subsonic API app name. + + +v0.3.4 (2017-06-12) +=================== + +- Playlist improvements. + + +v0.3.3 (2017-05-15) +=================== + +- Add API version setting. + + +v0.3.2 (2017-05-04) +=================== + +- Fix playlist track listing. + + +v0.3.1 (2017-03-23) +=================== + +- Fix URL encoding bug. + + +v0.3.0 (2017-03-22) +=================== + +- Add support for browsing. + + +v0.2.7 (2017-03-14) +=================== + +- Improved sorting of results. + + +v0.2.6 (2017-03-04) +=================== + +- Require py-sonic 0.6.1 to support legacy auth. + + +v0.2.5 (2017-02-27) +=================== + +- Fix legacy auth support. + + +v0.2.4 (2017-02-23) +=================== + +- Document current features/restrictions. + +- Fix bug. + + +v0.2.3 (2016-11-03) +=================== + +- Add more debug logging. + + +v0.2.2 (2016-11-02) +=================== + +- Improved error handling. + + +v0.2.1 (2016-09-22) +=================== + +- Improved search. + + +v0.2.0 (2016-09-22) +=================== + +- Add basic naive search. + + +v0.1.1 (2016-09-20) +=================== + +- Initial release. diff --git a/MANIFEST.in b/MANIFEST.in index 6def079..7734bb0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,15 @@ +include *.py +include *.rst +include .mailmap include LICENSE include MANIFEST.in -include README.md -include mopidy_subidy/ext.conf +include pyproject.toml +include tox.ini + +recursive-include .circleci * +recursive-include .github * + +include mopidy_*/ext.conf + +recursive-include tests *.py +recursive-include tests/data * diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..dc95100 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,35 @@ +# Maintainer: Matthew Gamble +# Contributor: Frederick Gnodtke + +pkgname=mopidy-subidy +pkgver=1.3.0 +pkgrel=2 +pkgdesc="Mopidy extension for playing music from Subsonic servers" +arch=("any") +url="https://git.hannover.ccc.de/lubiana/mopidy-subidy/releases" +license=('BSD') +depends=( + "mopidy" + "python" + "python-setuptools" + "python-pykka" + "python-pysonic" +) +source=("https://git.hannover.ccc.de/lubiana/mopidy-subidy/archive/1.0.0.tar.gz") +sha256sums=("ed78ce86da58fb42f6ddf9a8de72169d23521125b269b51054d69375b57c5b73") + +build() { + cd "mopidy-subidy" + + python setup.py build +} + +package() { + cd "mopidy-subidy" + + PYTHONHASHSEED=0 python setup.py install --root="${pkgdir}" --optimize=1 --skip-build + + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/mopidy-subidy/LICENSE" + install -Dm644 README.rst "${pkgdir}/usr/share/doc/mopidy-subidy/README.rst" + install -Dm644 CHANGELOG.rst "${pkgdir}/usr/share/doc/mopidy-subidy/CHANGELOG.rst" +} diff --git a/README.md b/README.md deleted file mode 100644 index 791da2c..0000000 --- a/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Mopidy Subidy - -A subsonic backend for mopidy using [py-sub](https://github.com/crustymonkey/py-sonic). - -## Configuration - -Add a section similiar to the following to your mopidy configuration: - -```ini -[subidy] -enabled=True -url=https://path.to/your/subsonic/server -username=subsonic_username -password=your_secret_password -legacy_auth=(optional; setting to yes may solve some connection errors) -``` - -## State of this plugin - -Plugin is developed against mopidy version 2.0.1. - -The following things are supported: - - * Browsing all artists/albums/tracks - * Searching for any terms - * Browsing playlists - * Searching explicitly for one of: artists, albums, tracks - -The following things are **not** supported: - - * Creating, editing and deleting playlists - * Subsonics smart playlists - * Searching for a combination of filters (artist and album, artist and track, etc.) - -## Contributors - -The following people contributed to this project: - - Frederick Gnodtke - - hhm0 diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..dc5e316 --- /dev/null +++ b/README.rst @@ -0,0 +1,81 @@ +************* +Mopidy-Subidy +************* + +.. image:: https://img.shields.io/pypi/v/Mopidy-Subidy + :target: https://pypi.org/project/Mopidy-Subidy/ + :alt: Latest PyPI version + +.. image:: https://img.shields.io/circleci/build/gh/Prior99/mopidy-subidy + :target: https://circleci.com/gh/Prior99/mopidy-subidy + :alt: CircleCI build status + +.. image:: https://img.shields.io/codecov/c/gh/Prior99/mopidy-subidy + :target: https://codecov.io/gh/Prior99/mopidy-subidy + :alt: Test coverage + +**This library is actively looking for maintainers to help out as I do not have the time or need to maintain this anymore. Please contact me if you feel that you could maintain this.** + +A Subsonic backend for Mopidy using `py-sonic +`_. + + +Installation +============ + +Install the latest release from PyPI by running:: + + python3 -m pip install Mopidy-Subidy + +Install the development version directly from this repo by running:: + + python3 -m pip install https://github.com/Prior99/mopidy-subidy/archive/master.zip + +See https://mopidy.com/ext/subidy/ for alternative installation methods. + + +Configuration +============= + +Before starting Mopidy, you must add configuration for Mopidy-Subidy to your +Mopidy configuration file:: + + [subidy] + url=https://path.to/your/subsonic/server + username=subsonic_username + password=your_secret_password + +In addition, the following optional configuration values are supported: + +- ``enabled`` -- Defaults to ``true``. Set to ``false`` to disable the + extension. + +- ``legacy_auth`` -- Defaults to ``false``. Setting to ``true`` may solve some + connection errors. + +- ``api_version`` -- Defaults to ``1.14.0``, which is the version used by + Subsonic 6.2. + + +State of this plugin +==================== + +The following things are supported: + +- Browsing all artists/albums/tracks +- Searching for any terms +- Browsing, creating, editing and deleting playlists +- Searching explicitly for one of: artists, albums, tracks + +The following things are **not** supported: + +- Subsonic's smart playlists +- Searching for a combination of filters (artist and album, artist and track, etc.) + + +Credits +======= + +- Original author: `Frederick Gnodtke `__ +- Current maintainer: `Frederick Gnodtke `__ +- `Contributors `_ diff --git a/mopidy_subidy/__init__.py b/mopidy_subidy/__init__.py index 46e3e36..d9f260a 100644 --- a/mopidy_subidy/__init__.py +++ b/mopidy_subidy/__init__.py @@ -1,30 +1,31 @@ -from __future__ import unicode_literals +import pathlib -import os +import pkg_resources -from mopidy import ext, config +from mopidy import config, ext -__version__ = '0.2.1' +__version__ = pkg_resources.get_distribution("Mopidy-Subidy").version class SubidyExtension(ext.Extension): - dist_name = 'Mopidy-Subidy' - ext_name = 'subidy' + 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) + return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self): - schema = super(SubidyExtension, self).get_config_schema() - schema['url'] = config.String() - schema['username'] = config.String() - schema['password'] = config.Secret() - schema['legacy_auth'] = config.Boolean(optional=True) + schema = super().get_config_schema() + schema["url"] = config.String() + schema["username"] = config.String() + schema["password"] = config.Secret() + schema["legacy_auth"] = config.Boolean(optional=True) + schema["api_version"] = config.String(optional=True) return schema def setup(self, registry): from .backend import SubidyBackend - registry.add('backend', SubidyBackend) + + registry.add("backend", SubidyBackend) diff --git a/mopidy_subidy/backend.py b/mopidy_subidy/backend.py index b757d32..4aef291 100644 --- a/mopidy_subidy/backend.py +++ b/mopidy_subidy/backend.py @@ -1,17 +1,25 @@ -from mopidy_subidy import library, playback, playlists, subsonic_api -from mopidy import backend import pykka +import mopidy_subidy +from mopidy import backend +from mopidy_subidy import library, playback, playlists, subsonic_api + + class SubidyBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): - super(SubidyBackend, self).__init__() - subidy_config = config['subidy'] + super().__init__() + subidy_config = config["subidy"] self.subsonic_api = subsonic_api.SubsonicApi( - url=subidy_config['url'], - username=subidy_config['username'], - password=subidy_config['password'], - legacy_auth=subidy_config['legacy_auth']) + url=subidy_config["url"], + username=subidy_config["username"], + password=subidy_config["password"], + app_name=mopidy_subidy.SubidyExtension.dist_name, + legacy_auth=subidy_config["legacy_auth"], + api_version=subidy_config["api_version"], + ) self.library = library.SubidyLibraryProvider(backend=self) - self.playback = playback.SubidyPlaybackProvider(audio=audio, backend=self) + self.playback = playback.SubidyPlaybackProvider( + audio=audio, backend=self + ) self.playlists = playlists.SubidyPlaylistsProvider(backend=self) - self.uri_schemes = ['subidy'] + self.uri_schemes = ["subidy"] diff --git a/mopidy_subidy/ext.conf b/mopidy_subidy/ext.conf index 354ca00..e935f13 100644 --- a/mopidy_subidy/ext.conf +++ b/mopidy_subidy/ext.conf @@ -4,3 +4,4 @@ url = username = password = legacy_auth = no +api_version = 1.14.0 diff --git a/mopidy_subidy/library.py b/mopidy_subidy/library.py index cd29d53..bc5e7c2 100644 --- a/mopidy_subidy/library.py +++ b/mopidy_subidy/library.py @@ -1,10 +1,12 @@ -from mopidy import backend, models +import logging + +from mopidy import backend from mopidy.models import Ref, SearchResult from mopidy_subidy import uri -import logging logger = logging.getLogger(__name__) + class SubidyLibraryProvider(backend.LibraryProvider): def __create_vdirs(): vdir_templates = [ @@ -12,6 +14,7 @@ class SubidyLibraryProvider(backend.LibraryProvider): dict(id="artists", name="Artists"), dict(id="albums", name="Albums"), dict(id="rootdirs", name="Directories"), + dict(id="random", name="Random"), ] # Create a dict with the keys being the `id`s in `vdir_templates` # and the values being objects containing the vdir `id`, @@ -20,7 +23,7 @@ class SubidyLibraryProvider(backend.LibraryProvider): for template in vdir_templates: vdir = template.copy() vdir.update(uri=uri.get_vdir_uri(vdir["id"])) - vdirs[template['id']] = vdir + vdirs[template["id"]] = vdir return vdirs _vdirs = __create_vdirs() @@ -28,16 +31,14 @@ class SubidyLibraryProvider(backend.LibraryProvider): def __raw_vdir_to_ref(vdir): if vdir is None: return None - return Ref.directory( - name=vdir['name'], - uri=vdir['uri']) + return Ref.directory(name=vdir["name"], uri=vdir["uri"]) - root_directory = __raw_vdir_to_ref(_vdirs['root']) + root_directory = __raw_vdir_to_ref(_vdirs["root"]) _raw_vdir_to_ref = staticmethod(__raw_vdir_to_ref) def __init__(self, *args, **kwargs): - super(SubidyLibraryProvider, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.subsonic_api = self.backend.subsonic_api def browse_songs(self, album_id): @@ -52,23 +53,46 @@ class SubidyLibraryProvider(backend.LibraryProvider): def browse_rootdirs(self): return self.subsonic_api.get_rootdirs_as_refs() + def browse_random_songs(self): + return self.subsonic_api.get_random_songs_as_refs() + def browse_diritems(self, directory_id): return self.subsonic_api.get_diritems_as_refs(directory_id) def lookup_song(self, song_id): - return self.subsonic_api.get_song_by_id(song_id) + song = self.subsonic_api.get_song_by_id(song_id) + if song is None: + return [] + else: + return [song] def lookup_album(self, album_id): - return self.subsonic_api.get_album_by_id(album_id) + return self.subsonic_api.get_songs_as_tracks(album_id) def lookup_artist(self, artist_id): - return self.subsonic_api.get_artist_by_id(artist_id) + return list( + self.subsonic_api.get_artist_as_songs_as_tracks_iter(artist_id) + ) + + def lookup_directory(self, directory_id): + return list( + self.subsonic_api.get_recursive_dir_as_songs_as_tracks_iter( + directory_id + ) + ) + + def lookup_playlist(self, playlist_id): + return self.subsonic_api.get_playlist_as_playlist(playlist_id).tracks def browse(self, browse_uri): - if browse_uri == uri.get_vdir_uri('root'): - root_vdir_names = ["rootdirs", "artists", "albums"] - root_vdirs = [self._vdirs[vdir_name] for vdir_name in root_vdir_names] - sorted_root_vdirs = sorted(root_vdirs, key=lambda vdir: vdir["name"]) + if browse_uri == uri.get_vdir_uri("root"): + root_vdir_names = ["rootdirs", "artists", "albums", "random"] + root_vdirs = [ + self._vdirs[vdir_name] for vdir_name in root_vdir_names + ] + sorted_root_vdirs = sorted( + root_vdirs, key=lambda vdir: vdir["name"] + ) return [self._raw_vdir_to_ref(vdir) for vdir in sorted_root_vdirs] elif browse_uri == uri.get_vdir_uri("rootdirs"): return self.browse_rootdirs() @@ -76,6 +100,9 @@ class SubidyLibraryProvider(backend.LibraryProvider): return self.browse_artists() elif browse_uri == uri.get_vdir_uri("albums"): return self.browse_albums() + elif browse_uri == uri.get_vdir_uri("random"): + return self.browse_random_songs() + else: uri_type = uri.get_type(browse_uri) if uri_type == uri.DIRECTORY: @@ -93,68 +120,91 @@ class SubidyLibraryProvider(backend.LibraryProvider): 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.DIRECTORY: + return self.lookup_directory(uri.get_directory_id(lookup_uri)) if type == uri.SONG: return self.lookup_song(uri.get_song_id(lookup_uri)) + if type == uri.PLAYLIST: + return self.lookup_playlist(uri.get_playlist_id(lookup_uri)) def lookup(self, uri=None, uris=None): if uris is not None: - return [self.lookup_one(uri) for uri in uris] + return {uri: self.lookup_one(uri) for uri in uris} if uri is not None: - return [self.lookup_one(uri)] + return self.lookup_one(uri) return None def refresh(self, uri): pass - def search_uri(self, query): - type = uri.get_type(lookup_uri) - if type == uri.ARTIST: - artist = self.lookup_artist(uri.get_artist_id(lookup_uri)) - if artist is not None: - return SearchResult(artists=[artist]) - elif type == uri.ALBUM: - album = self.lookup_album(uri.get_album_id(lookup_uri)) - if album is not None: - return SearchResult(albums=[album]) - elif type == uri.SONG: - song = self.lookup_song(uri.get_song_id(lookup_uri)) - if song is not None: - return SearchResult(tracks=[song]) - return None - - def search_by_artist_album_and_track(self, artist_name, album_name, track_name): + def search_by_artist_album_and_track( + self, artist_name, album_name, track_name + ): tracks = self.search_by_artist_and_album(artist_name, album_name) track = next(item for item in tracks.tracks if track_name in item.name) return SearchResult(tracks=[track]) def search_by_artist_and_album(self, artist_name, album_name): - artists = self.subsonic_api.get_raw_artists() - artist = next(item for item in artists if artist_name in item.get('name')) - albums = self.subsonic_api.get_raw_albums(artist.get('id')) - album = next(item for item in albums if album_name in item.get('title')) - return SearchResult(tracks=self.subsonic_api.get_songs_as_tracks(album.get('id'))) + artists = self.subsonic_api.find_raw(artist_name).get("artist") + if artists is None: + return None + tracks = [] + for artist in artists: + for album in self.subsonic_api.get_raw_albums(artist.get("id")): + if album_name in album.get("name"): + tracks.extend( + self.subsonic_api.get_songs_as_tracks(album.get("id")) + ) + return SearchResult(tracks=tracks) + + def search_by_artist(self, artist_name, exact): + result = self.subsonic_api.find_raw(artist_name) + if result is None: + return None + tracks = [] + for artist in result.get("artist"): + if exact: + if not artist.get("name") == artist_name: + continue + + tracks.extend( + self.subsonic_api.get_artist_as_songs_as_tracks_iter( + artist.get("id") + ) + ) + return SearchResult(uri=uri.get_search_uri(artist_name), tracks=tracks) def get_distinct(self, field, query): search_result = self.search(query) if not search_result: return [] - if field == 'track' or field == 'title': + if field == "track" or field == "title": return [track.name for track in (search_result.tracks or [])] - if field == 'album': + if field == "album": return [album.name for album in (search_result.albums or [])] - if field == 'artist': + if field == "artist": if not search_result.artists: return [artist.name for artist in self.browse_artists()] return [artist.name for artist in search_result.artists] def search(self, query=None, uris=None, exact=False): - if 'artist' in query and 'album' in query and 'track_name' in query: - return self.search_by_artist_album_and_track(query.get('artist')[0], query.get('album')[0], query.get('track_name')[0]) - if 'artist' in query and 'album' in query: - return self.search_by_artist_and_album(query.get('artist')[0], query.get('album')[0]) - if 'artist' in query: - return self.subsonic_api.find_as_search_result(query.get('artist')[0]) - if 'any' in query: - return self.subsonic_api.find_as_search_result(query.get('any')[0]) + if "artist" in query and "album" in query and "track_name" in query: + return self.search_by_artist_album_and_track( + query.get("artist")[0], + query.get("album")[0], + query.get("track_name")[0], + ) + if "artist" in query and "album" in query: + return self.search_by_artist_and_album( + query.get("artist")[0], query.get("album")[0] + ) + if "artist" in query: + return self.search_by_artist(query.get("artist")[0], exact) + if "comment" in query: + if query.get("comment")[0] == "random": + return SearchResult( + tracks=self.subsonic_api.get_random_songs_as_tracks() + ) + if "any" in query: + return self.subsonic_api.find_as_search_result(query.get("any")[0]) return SearchResult(artists=self.subsonic_api.get_artists_as_artists()) - diff --git a/mopidy_subidy/playback.py b/mopidy_subidy/playback.py index 81c0710..972446c 100644 --- a/mopidy_subidy/playback.py +++ b/mopidy_subidy/playback.py @@ -1,12 +1,14 @@ +import logging + from mopidy import backend from mopidy_subidy import uri -import logging logger = logging.getLogger(__name__) + class SubidyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): - super(SubidyPlaybackProvider, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.subsonic_api = self.backend.subsonic_api def translate_uri(self, translate_uri): @@ -14,3 +16,6 @@ class SubidyPlaybackProvider(backend.PlaybackProvider): censored_url = self.subsonic_api.get_censored_song_stream_uri(song_id) logger.debug("Loading song from subsonic with url: '%s'" % censored_url) return self.subsonic_api.get_song_stream_uri(song_id) + + def should_download(self, uri): + return True diff --git a/mopidy_subidy/playlists.py b/mopidy_subidy/playlists.py index 5cc97f1..c048643 100644 --- a/mopidy_subidy/playlists.py +++ b/mopidy_subidy/playlists.py @@ -1,35 +1,57 @@ +import logging + from mopidy import backend from mopidy_subidy import uri -import logging logger = logging.getLogger(__name__) + class SubidyPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, *args, **kwargs): - super(SubidyPlaylistsProvider, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.subsonic_api = self.backend.subsonic_api self.playlists = [] self.refresh() def as_list(self): - return self.playlists + return self.subsonic_api.get_playlists_as_refs() def create(self, name): - pass + result = self.subsonic_api.create_playlist_raw(name) + if result is None: + return None + playlist = result.get("playlist") + if playlist is None: + for pl in self.subsonic_api.get_playlists_as_playlists(): + if pl.name == name: + playlist = pl + return playlist + else: + return self.subsonic_api.raw_playlist_to_playlist(playlist) - def delete(self, uri): - pass + def delete(self, playlist_uri): + playlist_id = uri.get_playlist_id(playlist_uri) + self.subsonic_api.delete_playlist_raw(playlist_id) def get_items(self, items_uri): - #logger.info('ITEMS %s: %s' % (lookup_uri, self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri)))) - return self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri)) + return self.subsonic_api.get_playlist_as_songs_as_refs( + uri.get_playlist_id(items_uri) + ) def lookup(self, lookup_uri): - #logger.info('LOOKUP PLAYLIST %s: %s' % (lookup_uri, self.subsonic_api.get_playlist_as_playlist(uri.get_playlist_id(lookup_uri)))) - return self.subsonic_api.get_playlist_as_playlist(uri.get_playlist_id(lookup_uri)) + return self.subsonic_api.get_playlist_as_playlist( + uri.get_playlist_id(lookup_uri) + ) def refresh(self): - self.playlists = self.subsonic_api.get_playlists_as_refs() + pass def save(self, playlist): - pass + playlist_id = uri.get_playlist_id(playlist.uri) + track_ids = [] + for trk in playlist.tracks: + track_ids.append(uri.get_song_id(trk.uri)) + result = self.subsonic_api.save_playlist_raw(playlist_id, track_ids) + if result is None: + return None + return playlist diff --git a/mopidy_subidy/subsonic_api.py b/mopidy_subidy/subsonic_api.py index adc886e..50aacf7 100644 --- a/mopidy_subidy/subsonic_api.py +++ b/mopidy_subidy/subsonic_api.py @@ -1,26 +1,28 @@ -from urlparse import urlparse -from urllib import urlencode -import libsonic import logging -import itertools -from mopidy.models import Track, Album, Artist, Playlist, Ref, SearchResult import re +from urllib.parse import urlencode, urlparse + +import libsonic +from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track 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' +RESPONSE_OK = "ok" +UNKNOWN_SONG = "Unknown Song" +UNKNOWN_ALBUM = "Unknown Album" +UNKNOWN_ARTIST = "Unknown Artist" MAX_SEARCH_RESULTS = 100 MAX_LIST_RESULTS = 500 -ref_sort_key = lambda ref: ref.name + +def ref_sort_key(ref): + return ref.name + def string_nums_nocase_sort_key(s): segments = [] - for substr in re.split(r'(\d+)', s): + for substr in re.split(r"(\d+)", s): if substr.isdigit(): seg = int(substr) else: @@ -28,257 +30,517 @@ def string_nums_nocase_sort_key(s): segments.append(seg) return segments + def diritem_sort_key(item): - isdir = item['isDir'] + isdir = item["isDir"] if isdir: - key = string_nums_nocase_sort_key(item['title']) + key = string_nums_nocase_sort_key(item["title"]) else: - key = int(item.get('track', 1)) + key = int(item.get("track", 1)) return (isdir, key) -class SubsonicApi(): - def __init__(self, url, username, password, legacy_auth): + +class SubsonicApi: + def __init__( + self, url, username, password, app_name, legacy_auth, api_version + ): 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.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', - legacyAuth=legacy_auth) - self.url = url + '/rest' + parsed.path + "/rest", + appName=app_name, + legacyAuth=legacy_auth, + apiVersion=api_version, + ) + self.url = url + "/rest" self.username = username self.password = password - logger.info('Connecting to subsonic server on url %s as user %s' % (url, username)) + logger.info( + f"Connecting to subsonic server on url {url} as user {username}, " + f"API version {api_version}" + ) try: self.connection.ping() except Exception as e: - logger.error('Unabled to reach subsonic server: %s' % e) + logger.error("Unable to reach subsonic server: %s" % e) exit() def get_subsonic_uri(self, view_name, params, censor=False): di_params = {} di_params.update(params) - di_params.update(c='mopidy') + di_params.update(c=self.connection.appName) di_params.update(v=self.connection.apiVersion) if censor: - di_params.update(u='*****', p='*****') + di_params.update(u="*****", p="*****") else: di_params.update(u=self.username, p=self.password) - return '{}/{}.view?{}'.format(self.url, view_name, urlencode(di_params)) + return "{}/{}.view?{}".format(self.url, view_name, urlencode(di_params)) def get_song_stream_uri(self, song_id): - return self.get_subsonic_uri('stream', dict(id=song_id)) + return self.get_subsonic_uri("stream", dict(id=song_id)) def get_censored_song_stream_uri(self, song_id): - return self.get_subsonic_uri('stream', dict(id=song_id), True) + return self.get_subsonic_uri("stream", dict(id=song_id), True) - def find_raw(self, query, exclude_artists=False, exclude_albums=False, exclude_songs=False): + def find_raw( + self, + query, + exclude_artists=False, + exclude_albums=False, + exclude_songs=False, + ): try: - response = self.connection.search2( - query.encode('utf-8'), - MAX_SEARCH_RESULTS if not exclude_artists else 0, 0, - MAX_SEARCH_RESULTS if not exclude_albums else 0, 0, - MAX_SEARCH_RESULTS if not exclude_songs else 0, 0) - except Exception as e: - logger.warning('Connecting to subsonic failed when searching.') + response = self.connection.search3( + query.encode("utf-8"), + MAX_SEARCH_RESULTS if not exclude_artists else 0, + 0, + MAX_SEARCH_RESULTS if not exclude_albums else 0, + 0, + MAX_SEARCH_RESULTS if not exclude_songs else 0, + 0, + ) + except Exception: + logger.warning("Connecting to subsonic failed when searching.") return None - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return None - return response.get('searchResult2') + return response.get("searchResult3") - def find_as_search_result(self, query, exclude_artists=False, exclude_albums=False, exclude_songs=False): + def find_as_search_result( + self, + query, + exclude_artists=False, + exclude_albums=False, + exclude_songs=False, + ): result = self.find_raw(query) if result is None: return None return SearchResult( uri=uri.get_search_uri(query), - artists=[self.raw_artist_to_artist(artist) for artist in result.get('artist') or []], - albums=[self.raw_album_to_album(album) for album in result.get('album') or []], - tracks=[self.raw_song_to_track(song) for song in result.get('song') or []]) + artists=[ + self.raw_artist_to_artist(artist) + for artist in result.get("artist") or [] + ], + albums=[ + self.raw_album_to_album(album) + for album in result.get("album") or [] + ], + tracks=[ + self.raw_song_to_track(song) + for song in result.get("song") or [] + ], + ) + + def create_playlist_raw(self, name): + try: + response = self.connection.createPlaylist(name=name) + except Exception: + logger.warning( + "Connecting to subsonic failed when creating playlist." + ) + return None + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) + return None + return response + + def delete_playlist_raw(self, playlist_id): + try: + response = self.connection.deletePlaylist(playlist_id) + except Exception: + logger.warning( + "Connecting to subsonic failed when deleting playlist." + ) + return None + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) + return None + return response + + def save_playlist_raw(self, playlist_id, song_ids): + try: + response = self.connection.createPlaylist( + playlist_id, songIds=song_ids + ) + except Exception: + logger.warning( + "Connecting to subsonic failed when creating playlist." + ) + return None + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) + return None + return response def get_raw_artists(self): try: response = self.connection.getArtists() - except Exception as e: - logger.warning('Connecting to subsonic failed when loading list of artists.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading list of artists." + ) return [] - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return [] - letters = response.get('artists').get('index') + letters = response.get("artists").get("index") if letters is not None: - artists = [artist for letter in letters for artist in letter.get('artist') or []] + artists = [ + artist + for letter in letters + for artist in letter.get("artist") or [] + ] return artists - logger.warning('Subsonic does not seem to have any artists in it\'s library.') + logger.warning( + "Subsonic does not seem to have any artists in it's library." + ) return [] def get_raw_rootdirs(self): try: response = self.connection.getIndexes() - except Exception as e: - logger.warning('Connecting to subsonic failed when loading list of rootdirs.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading list of rootdirs." + ) return [] - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return [] - letters = response.get('indexes').get('index') + letters = response.get("indexes").get("index") if letters is not None: - artists = [artist for letter in letters for artist in letter.get('artist') or []] + artists = [ + artist + for letter in letters + for artist in letter.get("artist") or [] + ] return artists - logger.warning('Subsonic does not seem to have any rootdirs in its library.') + logger.warning( + "Subsonic does not seem to have any rootdirs in its library." + ) return [] def get_song_by_id(self, song_id): try: response = self.connection.getSong(song_id) - except Exception as e: - logger.warning('Connecting to subsonic failed when loading song by id.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading song by id." + ) return None - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return None - return self.raw_song_to_track(response.get('song')) if response.get('song') is not None else None + return ( + self.raw_song_to_track(response.get("song")) + if response.get("song") is not None + else None + ) def get_album_by_id(self, album_id): try: response = self.connection.getAlbum(album_id) - except Exception as e: - logger.warning('Connecting to subsonic failed when loading album by id.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading album by id." + ) return None - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return None - return self.raw_album_to_album(response.get('album')) if response.get('album') is not None else None + return ( + self.raw_album_to_album(response.get("album")) + if response.get("album") is not None + else None + ) def get_artist_by_id(self, artist_id): try: response = self.connection.getArtist(artist_id) - except Exception as e: - logger.warning('Connecting to subsonic failed when loading artist by id.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading artist by id." + ) return None - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return None - return self.raw_artist_to_artist(response.get('artist')) if response.get('artist') is not None else None + return ( + self.raw_artist_to_artist(response.get("artist")) + if response.get("artist") is not None + else None + ) def get_raw_playlists(self): try: response = self.connection.getPlaylists() - except Exception as e: - logger.warning('Connecting to subsonic failed when loading list of playlists.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading list of playlists." + ) return [] - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return [] - playlists = response.get('playlists').get('playlist') + playlists = response.get("playlists").get("playlist") if playlists is None: - logger.warning('Subsonic does not seem to have any playlists in it\'s library.') + logger.warning( + "Subsonic does not seem to have any playlists in it's library." + ) return [] return playlists def get_raw_playlist(self, playlist_id): try: response = self.connection.getPlaylist(playlist_id) - except Exception as e: - logger.warning('Connecting to subsonic failed when loading playlist.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading playlist." + ) return None - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return None - return response.get('playlist') + return response.get("playlist") def get_raw_dir(self, parent_id): try: response = self.connection.getMusicDirectory(parent_id) - except Exception as e: - logger.warning('Connecting to subsonic failed when listing content of music directory.') + except Exception: + logger.warning( + "Connecting to subsonic failed when listing content of music directory." + ) return None - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return None - directory = response.get('directory') + directory = response.get("directory") if directory is not None: - diritems = directory.get('child') + diritems = directory.get("child") return sorted(diritems, key=diritem_sort_key) return None def get_raw_albums(self, artist_id): try: response = self.connection.getArtist(artist_id) - except Exception as e: - logger.warning('Connecting to subsonic failed when loading list of albums.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading list of albums." + ) return [] - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return [] - albums = response.get('artist').get('album') + albums = response.get("artist").get("album") if albums is not None: - return sorted(albums, key=lambda album: string_nums_nocase_sort_key(album['name'])) + return sorted( + albums, + key=lambda album: string_nums_nocase_sort_key(album["name"]), + ) return [] def get_raw_songs(self, album_id): try: response = self.connection.getAlbum(album_id) - except Exception as e: - logger.warning('Connecting to subsonic failed when loading list of songs in album.') + except Exception: + logger.warning( + "Connecting to subsonic failed when loading list of songs in album." + ) return [] - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return [] - songs = response.get('album').get('song') + songs = response.get("album").get("song") if songs is not None: return songs return [] - def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS): + def get_raw_random_song(self, size=MAX_LIST_RESULTS): try: - response = self.connection.getAlbumList2(ltype=ltype, size=size) - except Exception as e: - logger.warning('Connecting to subsonic failed when loading album list.') + response = self.connection.getRandomSongs(size) + except Exception: + logger.warning( + "Connecting to subsonic failed when loading ramdom song list." + ) return [] - if response.get('status') != RESPONSE_OK: - logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) return [] - albums = response.get('albumList2').get('album') + songs = response.get("randomSongs").get("song") + if songs is not None: + return songs + return [] + + def get_more_albums(self, ltype, size=MAX_LIST_RESULTS, offset=0): + try: + response = self.connection.getAlbumList2( + ltype=ltype, size=size, offset=offset + ) + except Exception: + logger.warning( + "Connecting to subsonic failed when loading album list." + ) + return [] + if response.get("status") != RESPONSE_OK: + logger.warning( + "Got non-okay status code from subsonic: %s" + % response.get("status") + ) + return [] + albums = response.get("albumList2").get("album") if albums is not None: return albums return [] + def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS): + """ + Subsonic servers don't offer any way to retrieve the total number + of albums to get, and the spec states that the max number returned + for `getAlbumList2` is 500. To get all the albums, we make a + `getAlbumList2` request each time the response contains 500 albums. If + it does not, we assume we have all the albums and return them. + """ + offset = 0 + total = [] + albums = self.get_more_albums(ltype, size, offset) + total = albums + while len(albums) == size: + offset = offset + size + albums = self.get_more_albums(ltype, size, offset) + total = total + albums + return total + def get_albums_as_refs(self, artist_id=None): - albums = (self.get_raw_album_list('alphabeticalByName') if artist_id is None else self.get_raw_albums(artist_id)) + albums = ( + self.get_raw_album_list("alphabeticalByName") + if artist_id is None + else self.get_raw_albums(artist_id) + ) return [self.raw_album_to_ref(album) for album in albums] def get_albums_as_albums(self, artist_id): - return [self.raw_album_to_album(album) for album in self.get_raw_albums(artist_id)] + return [ + self.raw_album_to_album(album) + for album in self.get_raw_albums(artist_id) + ] def get_songs_as_refs(self, album_id): - return [self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)] + return [ + self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id) + ] def get_songs_as_tracks(self, album_id): - return [self.raw_song_to_track(song) for song in self.get_raw_songs(album_id)] + return [ + self.raw_song_to_track(song) + for song in self.get_raw_songs(album_id) + ] def get_artists_as_refs(self): - return [self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()] + return [ + self.raw_artist_to_ref(artist) for artist in self.get_raw_artists() + ] def get_rootdirs_as_refs(self): - return [self.raw_directory_to_ref(rootdir) for rootdir in self.get_raw_rootdirs()] + return [ + self.raw_directory_to_ref(rootdir) + for rootdir in self.get_raw_rootdirs() + ] def get_diritems_as_refs(self, directory_id): - return [(self.raw_directory_to_ref(diritem) if diritem.get('isDir') else self.raw_song_to_ref(diritem)) for diritem in self.get_raw_dir(directory_id)] + return [ + ( + self.raw_directory_to_ref(diritem) + if diritem.get("isDir") + else self.raw_song_to_ref(diritem) + ) + for diritem in self.get_raw_dir(directory_id) + ] + + def get_random_songs_as_refs(self): + return [ + self.raw_song_to_ref(song) for song in self.get_raw_random_song(75) + ] + + def get_random_songs_as_tracks(self): + return [ + self.raw_song_to_track(song) for song in self.get_raw_random_song() + ] def get_artists_as_artists(self): - return [self.raw_artist_to_artist(artist) for artist in self.get_raw_artists()] + return [ + self.raw_artist_to_artist(artist) + for artist in self.get_raw_artists() + ] def get_playlists_as_refs(self): - return [self.raw_playlist_to_ref(playlist) for playlist in self.get_raw_playlists()] + return [ + self.raw_playlist_to_ref(playlist) + for playlist in self.get_raw_playlists() + ] def get_playlists_as_playlists(self): - return [self.raw_playlist_to_playlist(playlist) for playlist in self.get_raw_playlists()] + return [ + self.raw_playlist_to_playlist(playlist) + for playlist in self.get_raw_playlists() + ] def get_playlist_as_playlist(self, playlist_id): return self.raw_playlist_to_playlist(self.get_raw_playlist(playlist_id)) @@ -287,85 +549,130 @@ class SubsonicApi(): playlist = self.get_raw_playlist(playlist_id) if playlist is None: return None - return [self.raw_song_to_ref(song) for song in playlist.get('entry')] + return [self.raw_song_to_ref(song) for song in playlist.get("entry")] + + def get_artist_as_songs_as_tracks_iter(self, artist_id): + albums = self.get_raw_albums(artist_id) + if albums is None: + return + for album in albums: + for song in self.get_raw_songs(album.get("id")): + yield self.raw_song_to_track(song) + + def get_recursive_dir_as_songs_as_tracks_iter(self, directory_id): + diritems = self.get_raw_dir(directory_id) + if diritems is None: + return + for item in diritems: + if item.get("isDir"): + yield from self.get_recursive_dir_as_songs_as_tracks_iter( + item.get("id") + ) + else: + yield self.raw_song_to_track(item) def raw_song_to_ref(self, song): if song is None: return None return Ref.track( - name=song.get('title') or UNKNOWN_SONG, - uri=uri.get_song_uri(song.get('id'))) + name=song.get("title") or UNKNOWN_SONG, + uri=uri.get_song_uri(song.get("id")), + ) def raw_song_to_track(self, song): if song is None: return 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( - name=song.get('artist'), - uri=uri.get_artist_uri(song.get('artistId')))], + 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( + name=song.get("artist"), + uri=uri.get_artist_uri(song.get("artistId")), + ) + ], album=Album( - name=song.get('album'), - uri=uri.get_album_uri(song.get('albumId')))) + name=song.get("album"), + uri=uri.get_album_uri(song.get("albumId")), + ), + ) def raw_album_to_ref(self, album): if album is None: return None return Ref.album( - name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, - uri=uri.get_album_uri(album.get('id'))) + name=album.get("title") or album.get("name") or UNKNOWN_ALBUM, + uri=uri.get_album_uri(album.get("id")), + ) def raw_album_to_album(self, album): if album is None: return None return Album( - name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, - uri=uri.get_album_uri(album.get('id')), - artists=[Artist( - name=album.get('artist'), - uri=uri.get_artist_uri(album.get('artistId')))]) + name=album.get("title") or album.get("name") or UNKNOWN_ALBUM, + num_tracks=album.get("songCount"), + uri=uri.get_album_uri(album.get("id")), + artists=[ + Artist( + name=album.get("artist"), + uri=uri.get_artist_uri(album.get("artistId")), + ) + ], + ) def raw_directory_to_ref(self, directory): if directory is None: return None return Ref.directory( - name=directory.get('title') or directory.get('name'), - uri=uri.get_directory_uri(directory.get('id'))) + name=directory.get("title") or directory.get("name"), + uri=uri.get_directory_uri(directory.get("id")), + ) def raw_artist_to_ref(self, artist): if artist is None: return None return Ref.artist( - name=artist.get('name') or UNKNOWN_ARTIST, - uri=uri.get_artist_uri(artist.get('id'))) + name=artist.get("name") or UNKNOWN_ARTIST, + uri=uri.get_artist_uri(artist.get("id")), + ) def raw_artist_to_artist(self, artist): if artist is None: return None return Artist( - name=artist.get('name') or UNKNOWN_ARTIST, - uri=uri.get_artist_uri(artist.get('id'))) + name=artist.get("name") or UNKNOWN_ARTIST, + uri=uri.get_artist_uri(artist.get("id")), + ) def raw_playlist_to_playlist(self, playlist): if playlist is None: return None - entries = playlist.get('entry') - tracks = [self.raw_song_to_track(song) for song in entries] if entries is not None else None + entries = playlist.get("entry") + tracks = ( + [self.raw_song_to_track(song) for song in entries] + if entries is not None + else None + ) return Playlist( - uri=uri.get_playlist_uri(playlist.get('id')), - name=playlist.get('name'), - tracks=tracks) + uri=uri.get_playlist_uri(playlist.get("id")), + name=playlist.get("name"), + tracks=tracks, + ) def raw_playlist_to_ref(self, playlist): if playlist is None: return None return Ref.playlist( - uri=uri.get_playlist_uri(playlist.get('id')), - name=playlist.get('name')) + uri=uri.get_playlist_uri(playlist.get("id")), + name=playlist.get("name"), + ) diff --git a/mopidy_subidy/uri.py b/mopidy_subidy/uri.py index 613f958..a4d8464 100644 --- a/mopidy_subidy/uri.py +++ b/mopidy_subidy/uri.py @@ -1,87 +1,110 @@ import re -SONG = 'song' -ARTIST = 'artist' -PLAYLIST = 'playlist' -ALBUM = 'album' -DIRECTORY = 'directory' -VDIR = 'vdir' -PREFIX = 'subidy' -SEARCH = 'search' +SONG = "song" +ARTIST = "artist" +PLAYLIST = "playlist" +ALBUM = "album" +DIRECTORY = "directory" +VDIR = "vdir" +PREFIX = "subidy" +SEARCH = "search" +RANDOM = "random" + +regex = re.compile(r"(\w+?):(\w+?)(?::|$)(.+?)?$") -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 + return ( + is_type_result_valid(result) + and result.group(1) == PREFIX + and result.group(2) == type + ) + def is_uri(uri): return regex.match(uri) is not None + 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_directory_id(uri): result = regex.match(uri) if not is_id_result_valid(result, DIRECTORY): return None return result.group(3) + def get_vdir_id(uri): result = regex.match(uri) if not is_id_result_valid(result, VDIR): 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) + return f"{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) + def get_directory_uri(id): return get_type_uri(DIRECTORY, id) + def get_vdir_uri(id): return get_type_uri(VDIR, id) + def get_playlist_uri(id): return get_type_uri(PLAYLIST, id) + def get_search_uri(query): return get_type_uri(SEARCH, query) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bff16e0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools >= 30.3.0", "wheel"] + + +[tool.black] +target-version = ["py37", "py38"] +line-length = 80 + + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +line_length = 88 +known_tests = "tests" +sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9610cce --- /dev/null +++ b/setup.cfg @@ -0,0 +1,86 @@ +[metadata] +name = Mopidy-Subidy +version = 1.1.0 +url = https://github.com/Prior99/mopidy-subidy +author = prior99 +author_email = fgnodtke@cronosx.de +license = BSD-3-Clause +license_file = LICENSE +description = Subsonic extension for Mopidy +long_description = file: README.rst +classifiers = + Environment :: No Input/Output (Daemon) + Intended Audience :: End Users/Desktop + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Topic :: Multimedia :: Sound/Audio :: Players + + +[options] +zip_safe = False +include_package_data = True +packages = find: +python_requires = >= 3.7 +install_requires = + Mopidy >= 3.0.0 + Pykka >= 2.0.1 + setuptools + py-sonic >= 0.7.7 + + +[options.extras_require] +lint = + black + check-manifest + flake8 + flake8-bugbear + flake8-import-order + isort[pyproject] +release = + twine + wheel +test = + pytest + pytest-cov +dev = + %(lint)s + %(release)s + %(test)s + + +[options.packages.find] +exclude = + tests + tests.* + + +[options.entry_points] +mopidy.ext = + subidy = mopidy_subidy:SubidyExtension + + +[flake8] +application-import-names = mopidy_subidy, tests +max-line-length = 80 +exclude = .git, .tox, build +select = + # Regular flake8 rules + C, E, F, W + # flake8-bugbear rules + B + # B950: line too long (soft speed limit) + B950 + # pep8-naming rules + N +ignore = + # E203: whitespace before ':' (not PEP8 compliant) + E203 + # E501: line too long (replaced by B950) + E501 + # W503: line break before binary operator (not PEP8 compliant) + W503 + # B305: .next() is not a thing on Python 3 (used by playback controller) + B305 diff --git a/setup.py b/setup.py index 713b741..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,43 +1,3 @@ -from __future__ import unicode_literals +from setuptools import setup -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='BSD-3-Clause', - author='prior99', - 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 >= 0.6.1', - '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 :: BSD 3-Clause', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Topic :: Multimedia :: Sound/Audio :: Players' - ] -) +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_extension.py b/tests/test_extension.py new file mode 100644 index 0000000..70ea844 --- /dev/null +++ b/tests/test_extension.py @@ -0,0 +1,23 @@ +from mopidy_subidy import SubidyExtension + + +def test_get_default_config(): + ext = SubidyExtension() + + config = ext.get_default_config() + + assert "[subidy]" in config + assert "enabled = true" in config + + +def test_get_config_schema(): + ext = SubidyExtension() + + schema = ext.get_config_schema() + + # TODO Test the content of your config schema + assert "url" in schema + # assert "password" in schema + + +# TODO Write more tests diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..50c10fe --- /dev/null +++ b/tox.ini @@ -0,0 +1,23 @@ +[tox] +envlist = py37, py38, black, check-manifest, flake8 + +[testenv] +sitepackages = true +deps = .[test] +commands = + python -m pytest \ + --basetemp={envtmpdir} \ + --cov=mopidy_subidy --cov-report=term-missing \ + {posargs} + +[testenv:black] +deps = .[lint] +commands = python -m black --check . + +[testenv:check-manifest] +deps = .[lint] +commands = python -m check_manifest + +[testenv:flake8] +deps = .[lint] +commands = python -m flake8 --show-source --statistics