Merge pull request #32 from jodal/python3

Modernize extension
This commit is contained in:
Frederick Gnodtke 2020-03-13 13:28:05 +01:00 committed by GitHub
commit 77f86bd2ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 918 additions and 351 deletions

51
.circleci/config.yml Normal file
View file

@ -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

13
.gitignore vendored
View file

@ -1,6 +1,9 @@
build/
dist/
*.conf
venv/
*.egg-info
*.pyc
/.coverage
/.mypy_cache/
/.pytest_cache/
/.tox/
/*.egg-info
/build/
/dist/
/MANIFEST

111
CHANGELOG.rst Normal file
View file

@ -0,0 +1,111 @@
*********
Changelog
*********
v1.0.0 (UNRELEASED)
===================
- 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.

View file

@ -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 *

View file

@ -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)
api_version=(optional - specify which API version to use. Subsonic 6.2 uses 1.14.0)
```
## 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, creating, editing and deleting playlists
* Searching explicitly for one of: artists, albums, tracks
The following things are **not** supported:
* 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

79
README.rst Normal file
View file

@ -0,0 +1,79 @@
*************
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
A Subsonic backend for Mopidy using `py-sonic
<https://github.com/crustymonkey/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 <https://github.com/Prior99>`__
- Current maintainer: `Frederick Gnodtke <https://github.com/Prior99>`__
- `Contributors <https://github.com/Prior99/mopidy-subidy/graphs/contributors>`_

View file

@ -1,31 +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['api_version'] = config.String(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)

View file

@ -1,20 +1,25 @@
import mopidy_subidy
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'],
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'])
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"]

View file

@ -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 = [
@ -20,7 +22,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 +30,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):
@ -66,19 +66,29 @@ class SubidyLibraryProvider(backend.LibraryProvider):
return self.subsonic_api.get_songs_as_tracks(album_id)
def lookup_artist(self, artist_id):
return list(self.subsonic_api.get_artist_as_songs_as_tracks_iter(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))
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'):
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"])
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()
@ -112,7 +122,7 @@ class SubidyLibraryProvider(backend.LibraryProvider):
def lookup(self, uri=None, uris=None):
if uris is not None:
return dict((uri, 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 None
@ -120,55 +130,52 @@ class SubidyLibraryProvider(backend.LibraryProvider):
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')))
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"))
)
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.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])
return SearchResult(artists=self.subsonic_api.get_artists_as_artists())

View file

@ -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):

View file

@ -1,13 +1,14 @@
import logging
from mopidy import backend
from mopidy_subidy import uri
from mopidy.models import Playlist
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()
@ -19,7 +20,7 @@ class SubidyPlaylistsProvider(backend.PlaylistsProvider):
result = self.subsonic_api.create_playlist_raw(name)
if result is None:
return None
playlist = result.get('playlist')
playlist = result.get("playlist")
if playlist is None:
for pl in self.subsonic_api.get_playlists_as_playlists():
if pl.name == name:
@ -33,12 +34,14 @@ class SubidyPlaylistsProvider(backend.PlaylistsProvider):
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_as_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):
pass

View file

@ -1,26 +1,28 @@
from urllib.parse import urlparse
from urllib.parse 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,37 +30,50 @@ 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, app_name, legacy_auth, api_version):
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',
parsed.path + "/rest",
appName=app_name,
legacyAuth=legacy_auth,
apiVersion=api_version)
self.url = url + '/rest'
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, API version %s' % (url, username, api_version))
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('Unable 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):
@ -67,205 +82,330 @@ class SubsonicApi():
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.')
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("searchResult2")
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 as e:
logger.warning('Connecting to subsonic failed when creating playlist.')
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'))
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 as e:
logger.warning('Connecting to subsonic failed when deleting playlist.')
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'))
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 as e:
logger.warning('Connecting to subsonic failed when creating playlist.')
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'))
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 []
@ -273,47 +413,85 @@ class SubsonicApi():
def get_raw_album_list(self, ltype, 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.')
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'))
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')
albums = response.get("albumList2").get("album")
if albums is not None:
return albums
return []
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_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))
@ -322,14 +500,14 @@ 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')):
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):
@ -337,9 +515,10 @@ class SubsonicApi():
if diritems is None:
return
for item in diritems:
if item.get('isDir'):
for song in self.get_recursive_dir_as_songs_as_tracks_iter(item.get('id')):
yield song
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)
@ -347,80 +526,104 @@ class SubsonicApi():
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,
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')))])
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"),
)

View file

@ -1,87 +1,109 @@
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"
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)

17
pyproject.toml Normal file
View file

@ -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"

86
setup.cfg Normal file
View file

@ -0,0 +1,86 @@
[metadata]
name = Mopidy-Subidy
version = 1.0.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

View file

@ -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.7.7',
'Pykka >= 1.1'
],
entry_points={
'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()

0
tests/__init__.py Normal file
View file

23
tests/test_extension.py Normal file
View file

@ -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

23
tox.ini Normal file
View file

@ -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