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 *.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 LICENSE
include MANIFEST.in include MANIFEST.in
include README.md include pyproject.toml
include mopidy_subidy/ext.conf 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): class SubidyExtension(ext.Extension):
dist_name = 'Mopidy-Subidy' dist_name = "Mopidy-Subidy"
ext_name = 'subidy' ext_name = "subidy"
version = __version__ version = __version__
def get_default_config(self): def get_default_config(self):
conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(pathlib.Path(__file__).parent / "ext.conf")
return config.read(conf_file)
def get_config_schema(self): def get_config_schema(self):
schema = super(SubidyExtension, self).get_config_schema() schema = super().get_config_schema()
schema['url'] = config.String() schema["url"] = config.String()
schema['username'] = config.String() schema["username"] = config.String()
schema['password'] = config.Secret() schema["password"] = config.Secret()
schema['legacy_auth'] = config.Boolean(optional=True) schema["legacy_auth"] = config.Boolean(optional=True)
schema['api_version'] = config.String(optional=True) schema["api_version"] = config.String(optional=True)
return schema return schema
def setup(self, registry): def setup(self, registry):
from .backend import SubidyBackend 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 pykka
import mopidy_subidy
from mopidy import backend
from mopidy_subidy import library, playback, playlists, subsonic_api
class SubidyBackend(pykka.ThreadingActor, backend.Backend): class SubidyBackend(pykka.ThreadingActor, backend.Backend):
def __init__(self, config, audio): def __init__(self, config, audio):
super(SubidyBackend, self).__init__() super().__init__()
subidy_config = config['subidy'] subidy_config = config["subidy"]
self.subsonic_api = subsonic_api.SubsonicApi( self.subsonic_api = subsonic_api.SubsonicApi(
url=subidy_config['url'], url=subidy_config["url"],
username=subidy_config['username'], username=subidy_config["username"],
password=subidy_config['password'], password=subidy_config["password"],
app_name=mopidy_subidy.SubidyExtension.dist_name, app_name=mopidy_subidy.SubidyExtension.dist_name,
legacy_auth=subidy_config['legacy_auth'], legacy_auth=subidy_config["legacy_auth"],
api_version=subidy_config['api_version']) api_version=subidy_config["api_version"],
)
self.library = library.SubidyLibraryProvider(backend=self) 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.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.models import Ref, SearchResult
from mopidy_subidy import uri from mopidy_subidy import uri
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SubidyLibraryProvider(backend.LibraryProvider): class SubidyLibraryProvider(backend.LibraryProvider):
def __create_vdirs(): def __create_vdirs():
vdir_templates = [ vdir_templates = [
@ -20,7 +22,7 @@ class SubidyLibraryProvider(backend.LibraryProvider):
for template in vdir_templates: for template in vdir_templates:
vdir = template.copy() vdir = template.copy()
vdir.update(uri=uri.get_vdir_uri(vdir["id"])) vdir.update(uri=uri.get_vdir_uri(vdir["id"]))
vdirs[template['id']] = vdir vdirs[template["id"]] = vdir
return vdirs return vdirs
_vdirs = __create_vdirs() _vdirs = __create_vdirs()
@ -28,16 +30,14 @@ class SubidyLibraryProvider(backend.LibraryProvider):
def __raw_vdir_to_ref(vdir): def __raw_vdir_to_ref(vdir):
if vdir is None: if vdir is None:
return None return None
return Ref.directory( return Ref.directory(name=vdir["name"], uri=vdir["uri"])
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) _raw_vdir_to_ref = staticmethod(__raw_vdir_to_ref)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SubidyLibraryProvider, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api self.subsonic_api = self.backend.subsonic_api
def browse_songs(self, album_id): def browse_songs(self, album_id):
@ -66,19 +66,29 @@ class SubidyLibraryProvider(backend.LibraryProvider):
return self.subsonic_api.get_songs_as_tracks(album_id) return self.subsonic_api.get_songs_as_tracks(album_id)
def lookup_artist(self, artist_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): 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): def lookup_playlist(self, playlist_id):
return self.subsonic_api.get_playlist_as_playlist(playlist_id).tracks return self.subsonic_api.get_playlist_as_playlist(playlist_id).tracks
def browse(self, browse_uri): 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_vdir_names = ["rootdirs", "artists", "albums"]
root_vdirs = [self._vdirs[vdir_name] for vdir_name in root_vdir_names] root_vdirs = [
sorted_root_vdirs = sorted(root_vdirs, key=lambda vdir: vdir["name"]) 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] return [self._raw_vdir_to_ref(vdir) for vdir in sorted_root_vdirs]
elif browse_uri == uri.get_vdir_uri("rootdirs"): elif browse_uri == uri.get_vdir_uri("rootdirs"):
return self.browse_rootdirs() return self.browse_rootdirs()
@ -112,7 +122,7 @@ class SubidyLibraryProvider(backend.LibraryProvider):
def lookup(self, uri=None, uris=None): def lookup(self, uri=None, uris=None):
if uris is not 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: if uri is not None:
return self.lookup_one(uri) return self.lookup_one(uri)
return None return None
@ -120,55 +130,52 @@ class SubidyLibraryProvider(backend.LibraryProvider):
def refresh(self, uri): def refresh(self, uri):
pass pass
def search_uri(self, query): def search_by_artist_album_and_track(
type = uri.get_type(lookup_uri) self, artist_name, album_name, track_name
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):
tracks = self.search_by_artist_and_album(artist_name, album_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) track = next(item for item in tracks.tracks if track_name in item.name)
return SearchResult(tracks=[track]) return SearchResult(tracks=[track])
def search_by_artist_and_album(self, artist_name, album_name): def search_by_artist_and_album(self, artist_name, album_name):
artists = self.subsonic_api.get_raw_artists() artists = self.subsonic_api.get_raw_artists()
artist = next(item for item in artists if artist_name in item.get('name')) artist = next(
albums = self.subsonic_api.get_raw_albums(artist.get('id')) item for item in artists if artist_name in item.get("name")
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'))) 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): def get_distinct(self, field, query):
search_result = self.search(query) search_result = self.search(query)
if not search_result: if not search_result:
return [] return []
if field == 'track' or field == 'title': if field == "track" or field == "title":
return [track.name for track in (search_result.tracks or [])] 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 [])] return [album.name for album in (search_result.albums or [])]
if field == 'artist': if field == "artist":
if not search_result.artists: if not search_result.artists:
return [artist.name for artist in self.browse_artists()] return [artist.name for artist in self.browse_artists()]
return [artist.name for artist in search_result.artists] return [artist.name for artist in search_result.artists]
def search(self, query=None, uris=None, exact=False): def search(self, query=None, uris=None, exact=False):
if 'artist' in query and 'album' in query and 'track_name' in query: 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]) return self.search_by_artist_album_and_track(
if 'artist' in query and 'album' in query: query.get("artist")[0],
return self.search_by_artist_and_album(query.get('artist')[0], query.get('album')[0]) query.get("album")[0],
if 'artist' in query: query.get("track_name")[0],
return self.subsonic_api.find_as_search_result(query.get('artist')[0]) )
if 'any' in query: if "artist" in query and "album" in query:
return self.subsonic_api.find_as_search_result(query.get('any')[0]) 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()) return SearchResult(artists=self.subsonic_api.get_artists_as_artists())

View file

@ -1,12 +1,14 @@
import logging
from mopidy import backend from mopidy import backend
from mopidy_subidy import uri from mopidy_subidy import uri
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SubidyPlaybackProvider(backend.PlaybackProvider): class SubidyPlaybackProvider(backend.PlaybackProvider):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SubidyPlaybackProvider, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api self.subsonic_api = self.backend.subsonic_api
def translate_uri(self, translate_uri): def translate_uri(self, translate_uri):

View file

@ -1,13 +1,14 @@
import logging
from mopidy import backend from mopidy import backend
from mopidy_subidy import uri from mopidy_subidy import uri
from mopidy.models import Playlist
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SubidyPlaylistsProvider(backend.PlaylistsProvider): class SubidyPlaylistsProvider(backend.PlaylistsProvider):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SubidyPlaylistsProvider, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api self.subsonic_api = self.backend.subsonic_api
self.playlists = [] self.playlists = []
self.refresh() self.refresh()
@ -19,7 +20,7 @@ class SubidyPlaylistsProvider(backend.PlaylistsProvider):
result = self.subsonic_api.create_playlist_raw(name) result = self.subsonic_api.create_playlist_raw(name)
if result is None: if result is None:
return None return None
playlist = result.get('playlist') playlist = result.get("playlist")
if playlist is None: if playlist is None:
for pl in self.subsonic_api.get_playlists_as_playlists(): for pl in self.subsonic_api.get_playlists_as_playlists():
if pl.name == name: if pl.name == name:
@ -33,12 +34,14 @@ class SubidyPlaylistsProvider(backend.PlaylistsProvider):
self.subsonic_api.delete_playlist_raw(playlist_id) self.subsonic_api.delete_playlist_raw(playlist_id)
def get_items(self, items_uri): 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(
return self.subsonic_api.get_playlist_as_songs_as_refs(uri.get_playlist_id(items_uri)) uri.get_playlist_id(items_uri)
)
def lookup(self, lookup_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(
return self.subsonic_api.get_playlist_as_playlist(uri.get_playlist_id(lookup_uri)) uri.get_playlist_id(lookup_uri)
)
def refresh(self): def refresh(self):
pass pass

View file

@ -1,26 +1,28 @@
from urllib.parse import urlparse
from urllib.parse import urlencode
import libsonic
import logging import logging
import itertools
from mopidy.models import Track, Album, Artist, Playlist, Ref, SearchResult
import re 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 from mopidy_subidy import uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RESPONSE_OK = u'ok' RESPONSE_OK = "ok"
UNKNOWN_SONG = u'Unknown Song' UNKNOWN_SONG = "Unknown Song"
UNKNOWN_ALBUM = u'Unknown Album' UNKNOWN_ALBUM = "Unknown Album"
UNKNOWN_ARTIST = u'Unknown Artist' UNKNOWN_ARTIST = "Unknown Artist"
MAX_SEARCH_RESULTS = 100 MAX_SEARCH_RESULTS = 100
MAX_LIST_RESULTS = 500 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): def string_nums_nocase_sort_key(s):
segments = [] segments = []
for substr in re.split(r'(\d+)', s): for substr in re.split(r"(\d+)", s):
if substr.isdigit(): if substr.isdigit():
seg = int(substr) seg = int(substr)
else: else:
@ -28,37 +30,50 @@ def string_nums_nocase_sort_key(s):
segments.append(seg) segments.append(seg)
return segments return segments
def diritem_sort_key(item): def diritem_sort_key(item):
isdir = item['isDir'] isdir = item["isDir"]
if isdir: if isdir:
key = string_nums_nocase_sort_key(item['title']) key = string_nums_nocase_sort_key(item["title"])
else: else:
key = int(item.get('track', 1)) key = int(item.get("track", 1))
return (isdir, key) 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) parsed = urlparse(url)
self.port = parsed.port if parsed.port else \ self.port = (
443 if parsed.scheme == 'https' else 80 parsed.port
base_url = parsed.scheme + '://' + parsed.hostname if parsed.port
else 443
if parsed.scheme == "https"
else 80
)
base_url = parsed.scheme + "://" + parsed.hostname
self.connection = libsonic.Connection( self.connection = libsonic.Connection(
base_url, base_url,
username, username,
password, password,
self.port, self.port,
parsed.path + '/rest', parsed.path + "/rest",
appName=app_name, appName=app_name,
legacyAuth=legacy_auth, legacyAuth=legacy_auth,
apiVersion=api_version) apiVersion=api_version,
self.url = url + '/rest' )
self.url = url + "/rest"
self.username = username self.username = username
self.password = password 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: try:
self.connection.ping() self.connection.ping()
except Exception as e: except Exception as e:
logger.error('Unable to reach subsonic server: %s' % e) logger.error("Unable to reach subsonic server: %s" % e)
exit() exit()
def get_subsonic_uri(self, view_name, params, censor=False): 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(c=self.connection.appName)
di_params.update(v=self.connection.apiVersion) di_params.update(v=self.connection.apiVersion)
if censor: if censor:
di_params.update(u='*****', p='*****') di_params.update(u="*****", p="*****")
else: else:
di_params.update(u=self.username, p=self.password) 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): 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): 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: try:
response = self.connection.search2( response = self.connection.search2(
query.encode('utf-8'), query.encode("utf-8"),
MAX_SEARCH_RESULTS if not exclude_artists else 0, 0, MAX_SEARCH_RESULTS if not exclude_artists else 0,
MAX_SEARCH_RESULTS if not exclude_albums else 0, 0, 0,
MAX_SEARCH_RESULTS if not exclude_songs else 0, 0) MAX_SEARCH_RESULTS if not exclude_albums else 0,
except Exception as e: 0,
logger.warning('Connecting to subsonic failed when searching.') MAX_SEARCH_RESULTS if not exclude_songs else 0,
0,
)
except Exception:
logger.warning("Connecting to subsonic failed when searching.")
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None 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) result = self.find_raw(query)
if result is None: if result is None:
return None return None
return SearchResult( return SearchResult(
uri=uri.get_search_uri(query), uri=uri.get_search_uri(query),
artists=[self.raw_artist_to_artist(artist) for artist in result.get('artist') or []], artists=[
albums=[self.raw_album_to_album(album) for album in result.get('album') or []], self.raw_artist_to_artist(artist)
tracks=[self.raw_song_to_track(song) for song in result.get('song') or []]) 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): def create_playlist_raw(self, name):
try: try:
response = self.connection.createPlaylist(name=name) response = self.connection.createPlaylist(name=name)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when creating playlist.') logger.warning(
"Connecting to subsonic failed when creating playlist."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return response return response
def delete_playlist_raw(self, playlist_id): def delete_playlist_raw(self, playlist_id):
try: try:
response = self.connection.deletePlaylist(playlist_id) response = self.connection.deletePlaylist(playlist_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when deleting playlist.') logger.warning(
"Connecting to subsonic failed when deleting playlist."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return response return response
def save_playlist_raw(self, playlist_id, song_ids): def save_playlist_raw(self, playlist_id, song_ids):
try: try:
response = self.connection.createPlaylist(playlist_id, songIds=song_ids) response = self.connection.createPlaylist(
except Exception as e: playlist_id, songIds=song_ids
logger.warning('Connecting to subsonic failed when creating playlist.') )
except Exception:
logger.warning(
"Connecting to subsonic failed when creating playlist."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return response return response
def get_raw_artists(self): def get_raw_artists(self):
try: try:
response = self.connection.getArtists() response = self.connection.getArtists()
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of artists.') logger.warning(
"Connecting to subsonic failed when loading list of artists."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
letters = response.get('artists').get('index') letters = response.get("artists").get("index")
if letters is not None: 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 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 [] return []
def get_raw_rootdirs(self): def get_raw_rootdirs(self):
try: try:
response = self.connection.getIndexes() response = self.connection.getIndexes()
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of rootdirs.') logger.warning(
"Connecting to subsonic failed when loading list of rootdirs."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
letters = response.get('indexes').get('index') letters = response.get("indexes").get("index")
if letters is not None: 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 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 [] return []
def get_song_by_id(self, song_id): def get_song_by_id(self, song_id):
try: try:
response = self.connection.getSong(song_id) response = self.connection.getSong(song_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading song by id.') logger.warning(
"Connecting to subsonic failed when loading song by id."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None 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): def get_album_by_id(self, album_id):
try: try:
response = self.connection.getAlbum(album_id) response = self.connection.getAlbum(album_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading album by id.') logger.warning(
"Connecting to subsonic failed when loading album by id."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None 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): def get_artist_by_id(self, artist_id):
try: try:
response = self.connection.getArtist(artist_id) response = self.connection.getArtist(artist_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading artist by id.') logger.warning(
"Connecting to subsonic failed when loading artist by id."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None 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): def get_raw_playlists(self):
try: try:
response = self.connection.getPlaylists() response = self.connection.getPlaylists()
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of playlists.') logger.warning(
"Connecting to subsonic failed when loading list of playlists."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
playlists = response.get('playlists').get('playlist') playlists = response.get("playlists").get("playlist")
if playlists is None: 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 []
return playlists return playlists
def get_raw_playlist(self, playlist_id): def get_raw_playlist(self, playlist_id):
try: try:
response = self.connection.getPlaylist(playlist_id) response = self.connection.getPlaylist(playlist_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading playlist.') logger.warning(
"Connecting to subsonic failed when loading playlist."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return response.get('playlist') return response.get("playlist")
def get_raw_dir(self, parent_id): def get_raw_dir(self, parent_id):
try: try:
response = self.connection.getMusicDirectory(parent_id) response = self.connection.getMusicDirectory(parent_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when listing content of music directory.') logger.warning(
"Connecting to subsonic failed when listing content of music directory."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
directory = response.get('directory') directory = response.get("directory")
if directory is not None: if directory is not None:
diritems = directory.get('child') diritems = directory.get("child")
return sorted(diritems, key=diritem_sort_key) return sorted(diritems, key=diritem_sort_key)
return None return None
def get_raw_albums(self, artist_id): def get_raw_albums(self, artist_id):
try: try:
response = self.connection.getArtist(artist_id) response = self.connection.getArtist(artist_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of albums.') logger.warning(
"Connecting to subsonic failed when loading list of albums."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
albums = response.get('artist').get('album') albums = response.get("artist").get("album")
if albums is not None: 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 [] return []
def get_raw_songs(self, album_id): def get_raw_songs(self, album_id):
try: try:
response = self.connection.getAlbum(album_id) response = self.connection.getAlbum(album_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of songs in album.') logger.warning(
"Connecting to subsonic failed when loading list of songs in album."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
songs = response.get('album').get('song') songs = response.get("album").get("song")
if songs is not None: if songs is not None:
return songs return songs
return [] return []
@ -273,47 +413,85 @@ class SubsonicApi():
def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS): def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS):
try: try:
response = self.connection.getAlbumList2(ltype=ltype, size=size) response = self.connection.getAlbumList2(ltype=ltype, size=size)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading album list.') logger.warning(
"Connecting to subsonic failed when loading album list."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
albums = response.get('albumList2').get('album') albums = response.get("albumList2").get("album")
if albums is not None: if albums is not None:
return albums return albums
return [] return []
def get_albums_as_refs(self, artist_id=None): 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] return [self.raw_album_to_ref(album) for album in albums]
def get_albums_as_albums(self, artist_id): 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): 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): 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): 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): 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): 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): 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): 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): 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): def get_playlist_as_playlist(self, playlist_id):
return self.raw_playlist_to_playlist(self.get_raw_playlist(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) playlist = self.get_raw_playlist(playlist_id)
if playlist is None: if playlist is None:
return 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): def get_artist_as_songs_as_tracks_iter(self, artist_id):
albums = self.get_raw_albums(artist_id) albums = self.get_raw_albums(artist_id)
if albums is None: if albums is None:
return return
for album in albums: 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) yield self.raw_song_to_track(song)
def get_recursive_dir_as_songs_as_tracks_iter(self, directory_id): def get_recursive_dir_as_songs_as_tracks_iter(self, directory_id):
@ -337,9 +515,10 @@ class SubsonicApi():
if diritems is None: if diritems is None:
return return
for item in diritems: for item in diritems:
if item.get('isDir'): if item.get("isDir"):
for song in self.get_recursive_dir_as_songs_as_tracks_iter(item.get('id')): yield from self.get_recursive_dir_as_songs_as_tracks_iter(
yield song item.get("id")
)
else: else:
yield self.raw_song_to_track(item) yield self.raw_song_to_track(item)
@ -347,80 +526,104 @@ class SubsonicApi():
if song is None: if song is None:
return None return None
return Ref.track( return Ref.track(
name=song.get('title') or UNKNOWN_SONG, name=song.get("title") or UNKNOWN_SONG,
uri=uri.get_song_uri(song.get('id'))) uri=uri.get_song_uri(song.get("id")),
)
def raw_song_to_track(self, song): def raw_song_to_track(self, song):
if song is None: if song is None:
return None return None
return Track( return Track(
name=song.get('title') or UNKNOWN_SONG, name=song.get("title") or UNKNOWN_SONG,
uri=uri.get_song_uri(song.get('id')), uri=uri.get_song_uri(song.get("id")),
bitrate=song.get('bitRate'), bitrate=song.get("bitRate"),
track_no=int(song.get('track')) if song.get('track') else None, track_no=int(song.get("track")) if song.get("track") else None,
date=str(song.get('year')) or 'none', date=str(song.get("year")) or "none",
genre=song.get('genre'), genre=song.get("genre"),
length=int(song.get('duration')) * 1000 if song.get('duration') else None, length=int(song.get("duration")) * 1000
disc_no=int(song.get('discNumber')) if song.get('discNumber') else None, if song.get("duration")
artists=[Artist( else None,
name=song.get('artist'), disc_no=int(song.get("discNumber"))
uri=uri.get_artist_uri(song.get('artistId')))], if song.get("discNumber")
else None,
artists=[
Artist(
name=song.get("artist"),
uri=uri.get_artist_uri(song.get("artistId")),
)
],
album=Album( album=Album(
name=song.get('album'), name=song.get("album"),
uri=uri.get_album_uri(song.get('albumId')))) uri=uri.get_album_uri(song.get("albumId")),
),
)
def raw_album_to_ref(self, album): def raw_album_to_ref(self, album):
if album is None: if album is None:
return None return None
return Ref.album( return Ref.album(
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, name=album.get("title") or album.get("name") or UNKNOWN_ALBUM,
uri=uri.get_album_uri(album.get('id'))) uri=uri.get_album_uri(album.get("id")),
)
def raw_album_to_album(self, album): def raw_album_to_album(self, album):
if album is None: if album is None:
return None return None
return Album( return Album(
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, name=album.get("title") or album.get("name") or UNKNOWN_ALBUM,
num_tracks=album.get('songCount'), num_tracks=album.get("songCount"),
uri=uri.get_album_uri(album.get('id')), uri=uri.get_album_uri(album.get("id")),
artists=[Artist( artists=[
name=album.get('artist'), Artist(
uri=uri.get_artist_uri(album.get('artistId')))]) name=album.get("artist"),
uri=uri.get_artist_uri(album.get("artistId")),
)
],
)
def raw_directory_to_ref(self, directory): def raw_directory_to_ref(self, directory):
if directory is None: if directory is None:
return None return None
return Ref.directory( return Ref.directory(
name=directory.get('title') or directory.get('name'), name=directory.get("title") or directory.get("name"),
uri=uri.get_directory_uri(directory.get('id'))) uri=uri.get_directory_uri(directory.get("id")),
)
def raw_artist_to_ref(self, artist): def raw_artist_to_ref(self, artist):
if artist is None: if artist is None:
return None return None
return Ref.artist( return Ref.artist(
name=artist.get('name') or UNKNOWN_ARTIST, name=artist.get("name") or UNKNOWN_ARTIST,
uri=uri.get_artist_uri(artist.get('id'))) uri=uri.get_artist_uri(artist.get("id")),
)
def raw_artist_to_artist(self, artist): def raw_artist_to_artist(self, artist):
if artist is None: if artist is None:
return None return None
return Artist( return Artist(
name=artist.get('name') or UNKNOWN_ARTIST, name=artist.get("name") or UNKNOWN_ARTIST,
uri=uri.get_artist_uri(artist.get('id'))) uri=uri.get_artist_uri(artist.get("id")),
)
def raw_playlist_to_playlist(self, playlist): def raw_playlist_to_playlist(self, playlist):
if playlist is None: if playlist is None:
return None return None
entries = playlist.get('entry') entries = playlist.get("entry")
tracks = [self.raw_song_to_track(song) for song in entries] if entries is not None else None tracks = (
[self.raw_song_to_track(song) for song in entries]
if entries is not None
else None
)
return Playlist( return Playlist(
uri=uri.get_playlist_uri(playlist.get('id')), uri=uri.get_playlist_uri(playlist.get("id")),
name=playlist.get('name'), name=playlist.get("name"),
tracks=tracks) tracks=tracks,
)
def raw_playlist_to_ref(self, playlist): def raw_playlist_to_ref(self, playlist):
if playlist is None: if playlist is None:
return None return None
return Ref.playlist( return Ref.playlist(
uri=uri.get_playlist_uri(playlist.get('id')), uri=uri.get_playlist_uri(playlist.get("id")),
name=playlist.get('name')) name=playlist.get("name"),
)

View file

@ -1,87 +1,109 @@
import re import re
SONG = 'song' SONG = "song"
ARTIST = 'artist' ARTIST = "artist"
PLAYLIST = 'playlist' PLAYLIST = "playlist"
ALBUM = 'album' ALBUM = "album"
DIRECTORY = 'directory' DIRECTORY = "directory"
VDIR = 'vdir' VDIR = "vdir"
PREFIX = 'subidy' PREFIX = "subidy"
SEARCH = 'search' SEARCH = "search"
regex = re.compile(r"(\w+?):(\w+?)(?::|$)(.+?)?$")
regex = re.compile(r'(\w+?):(\w+?)(?::|$)(.+?)?$')
def is_type_result_valid(result): def is_type_result_valid(result):
return result is not None and result.group(1) == PREFIX return result is not None and result.group(1) == PREFIX
def is_id_result_valid(result, type): 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): def is_uri(uri):
return regex.match(uri) is not None return regex.match(uri) is not None
def get_song_id(uri): def get_song_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, SONG): if not is_id_result_valid(result, SONG):
return None return None
return result.group(3) return result.group(3)
def get_artist_id(uri): def get_artist_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, ARTIST): if not is_id_result_valid(result, ARTIST):
return None return None
return result.group(3) return result.group(3)
def get_playlist_id(uri): def get_playlist_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, PLAYLIST): if not is_id_result_valid(result, PLAYLIST):
return None return None
return result.group(3) return result.group(3)
def get_album_id(uri): def get_album_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, ALBUM): if not is_id_result_valid(result, ALBUM):
return None return None
return result.group(3) return result.group(3)
def get_directory_id(uri): def get_directory_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, DIRECTORY): if not is_id_result_valid(result, DIRECTORY):
return None return None
return result.group(3) return result.group(3)
def get_vdir_id(uri): def get_vdir_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, VDIR): if not is_id_result_valid(result, VDIR):
return None return None
return result.group(3) return result.group(3)
def get_type(uri): def get_type(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_type_result_valid(result): if not is_type_result_valid(result):
return None return None
return result.group(2) return result.group(2)
def get_type_uri(type, id): def get_type_uri(type, id):
return u'%s:%s:%s' % (PREFIX, type, id) return f"{PREFIX}:{type}:{id}"
def get_artist_uri(id): def get_artist_uri(id):
return get_type_uri(ARTIST, id) return get_type_uri(ARTIST, id)
def get_album_uri(id): def get_album_uri(id):
return get_type_uri(ALBUM, id) return get_type_uri(ALBUM, id)
def get_song_uri(id): def get_song_uri(id):
return get_type_uri(SONG, id) return get_type_uri(SONG, id)
def get_directory_uri(id): def get_directory_uri(id):
return get_type_uri(DIRECTORY, id) return get_type_uri(DIRECTORY, id)
def get_vdir_uri(id): def get_vdir_uri(id):
return get_type_uri(VDIR, id) return get_type_uri(VDIR, id)
def get_playlist_uri(id): def get_playlist_uri(id):
return get_type_uri(PLAYLIST, id) return get_type_uri(PLAYLIST, id)
def get_search_uri(query): def get_search_uri(query):
return get_type_uri(SEARCH, 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 setup()
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'
]
)

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