Compare commits

..

No commits in common. "master" and "hhm0-to_browse" have entirely different histories.

21 changed files with 347 additions and 1136 deletions

View file

@ -1,51 +0,0 @@
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,9 +1,6 @@
build/
dist/
*.conf
venv/
*.egg-info
*.pyc
/.coverage
/.mypy_cache/
/.pytest_cache/
/.tox/
/*.egg-info
/build/
/dist/
/MANIFEST

View file

@ -1,111 +0,0 @@
*********
Changelog
*********
v1.0.0 (2020-03-13)
===================
- Require Mopidy 3.0 or newer.
- Update extension to match the Mopidy extension cookiecutter.
v0.4.1 (2020-02-01)
===================
- Require Python 3.7 or newer.
- Require py-sonic 0.7.7 or newer.
v0.4.0 (2017-08-14)
===================
- Use Mopidy extension name as Subsonic API app name.
v0.3.4 (2017-06-12)
===================
- Playlist improvements.
v0.3.3 (2017-05-15)
===================
- Add API version setting.
v0.3.2 (2017-05-04)
===================
- Fix playlist track listing.
v0.3.1 (2017-03-23)
===================
- Fix URL encoding bug.
v0.3.0 (2017-03-22)
===================
- Add support for browsing.
v0.2.7 (2017-03-14)
===================
- Improved sorting of results.
v0.2.6 (2017-03-04)
===================
- Require py-sonic 0.6.1 to support legacy auth.
v0.2.5 (2017-02-27)
===================
- Fix legacy auth support.
v0.2.4 (2017-02-23)
===================
- Document current features/restrictions.
- Fix bug.
v0.2.3 (2016-11-03)
===================
- Add more debug logging.
v0.2.2 (2016-11-02)
===================
- Improved error handling.
v0.2.1 (2016-09-22)
===================
- Improved search.
v0.2.0 (2016-09-22)
===================
- Add basic naive search.
v0.1.1 (2016-09-20)
===================
- Initial release.

View file

@ -1,15 +1,4 @@
include *.py
include *.rst
include .mailmap
include LICENSE
include MANIFEST.in
include pyproject.toml
include tox.ini
recursive-include .circleci *
recursive-include .github *
include mopidy_*/ext.conf
recursive-include tests *.py
recursive-include tests/data *
include README.md
include mopidy_subidy/ext.conf

View file

@ -1,35 +0,0 @@
# Maintainer: Matthew Gamble <git@matthewgamble.net>
# Contributor: Frederick Gnodtke <fgnodtke at cronosx dot de>
pkgname=mopidy-subidy
pkgver=1.3.0
pkgrel=2
pkgdesc="Mopidy extension for playing music from Subsonic servers"
arch=("any")
url="https://git.hannover.ccc.de/lubiana/mopidy-subidy/releases"
license=('BSD')
depends=(
"mopidy"
"python"
"python-setuptools"
"python-pykka"
"python-pysonic"
)
source=("https://git.hannover.ccc.de/lubiana/mopidy-subidy/archive/1.0.0.tar.gz")
sha256sums=("ed78ce86da58fb42f6ddf9a8de72169d23521125b269b51054d69375b57c5b73")
build() {
cd "mopidy-subidy"
python setup.py build
}
package() {
cd "mopidy-subidy"
PYTHONHASHSEED=0 python setup.py install --root="${pkgdir}" --optimize=1 --skip-build
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/mopidy-subidy/LICENSE"
install -Dm644 README.rst "${pkgdir}/usr/share/doc/mopidy-subidy/README.rst"
install -Dm644 CHANGELOG.rst "${pkgdir}/usr/share/doc/mopidy-subidy/CHANGELOG.rst"
}

39
README.md Normal file
View file

@ -0,0 +1,39 @@
# Mopidy Subidy
A subsonic backend for mopidy using [py-sub](https://github.com/crustymonkey/py-sonic).
## Configuration
Add a section similiar to the following to your mopidy configuration:
```ini
[subidy]
enabled=True
url=https://path.to/your/subsonic/server
username=subsonic_username
password=your_secret_password
legacy_auth=(optional; setting to yes may solve some connection errors)
```
## State of this plugin
Plugin is developed against mopidy version 2.0.1.
The following things are supported:
* Browsing all artists/albums/tracks
* Searching for any terms
* Browsing playlists
* Searching explicitly for one of: artists, albums, tracks
The following things are **not** supported:
* Creating, editing and deleting playlists
* Subsonics smart playlists
* Searching for a combination of filters (artist and album, artist and track, etc.)
## Contributors
The following people contributed to this project:
- Frederick Gnodtke
- hhm0

View file

@ -1,81 +0,0 @@
*************
Mopidy-Subidy
*************
.. image:: https://img.shields.io/pypi/v/Mopidy-Subidy
:target: https://pypi.org/project/Mopidy-Subidy/
:alt: Latest PyPI version
.. image:: https://img.shields.io/circleci/build/gh/Prior99/mopidy-subidy
:target: https://circleci.com/gh/Prior99/mopidy-subidy
:alt: CircleCI build status
.. image:: https://img.shields.io/codecov/c/gh/Prior99/mopidy-subidy
:target: https://codecov.io/gh/Prior99/mopidy-subidy
:alt: Test coverage
**This library is actively looking for maintainers to help out as I do not have the time or need to maintain this anymore. Please contact me if you feel that you could maintain this.**
A Subsonic backend for Mopidy using `py-sonic
<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,30 @@
import pathlib
from __future__ import unicode_literals
import pkg_resources
import os
from mopidy import config, ext
from mopidy import ext, config
__version__ = pkg_resources.get_distribution("Mopidy-Subidy").version
__version__ = '0.2.1'
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):
return config.read(pathlib.Path(__file__).parent / "ext.conf")
conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf')
return config.read(conf_file)
def get_config_schema(self):
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)
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)
return schema
def setup(self, registry):
from .backend import SubidyBackend
registry.add("backend", SubidyBackend)
registry.add('backend', SubidyBackend)

View file

@ -1,25 +1,17 @@
import pykka
import mopidy_subidy
from mopidy import backend
from mopidy_subidy import library, playback, playlists, subsonic_api
from mopidy import backend
import pykka
class SubidyBackend(pykka.ThreadingActor, backend.Backend):
def __init__(self, config, audio):
super().__init__()
subidy_config = config["subidy"]
super(SubidyBackend, self).__init__()
subidy_config = config['subidy']
self.subsonic_api = subsonic_api.SubsonicApi(
url=subidy_config["url"],
username=subidy_config["username"],
password=subidy_config["password"],
app_name=mopidy_subidy.SubidyExtension.dist_name,
legacy_auth=subidy_config["legacy_auth"],
api_version=subidy_config["api_version"],
)
url=subidy_config['url'],
username=subidy_config['username'],
password=subidy_config['password'],
legacy_auth=subidy_config['legacy_auth'])
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

@ -4,4 +4,3 @@ url =
username =
password =
legacy_auth = no
api_version = 1.14.0

View file

@ -1,12 +1,10 @@
import logging
from mopidy import backend
from mopidy import backend, models
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 = [
@ -14,7 +12,6 @@ class SubidyLibraryProvider(backend.LibraryProvider):
dict(id="artists", name="Artists"),
dict(id="albums", name="Albums"),
dict(id="rootdirs", name="Directories"),
dict(id="random", name="Random"),
]
# Create a dict with the keys being the `id`s in `vdir_templates`
# and the values being objects containing the vdir `id`,
@ -23,7 +20,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()
@ -31,14 +28,16 @@ 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().__init__(*args, **kwargs)
super(SubidyLibraryProvider, self).__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api
def browse_songs(self, album_id):
@ -53,46 +52,23 @@ class SubidyLibraryProvider(backend.LibraryProvider):
def browse_rootdirs(self):
return self.subsonic_api.get_rootdirs_as_refs()
def browse_random_songs(self):
return self.subsonic_api.get_random_songs_as_refs()
def browse_diritems(self, directory_id):
return self.subsonic_api.get_diritems_as_refs(directory_id)
def lookup_song(self, song_id):
song = self.subsonic_api.get_song_by_id(song_id)
if song is None:
return []
else:
return [song]
return self.subsonic_api.get_song_by_id(song_id)
def lookup_album(self, album_id):
return self.subsonic_api.get_songs_as_tracks(album_id)
return self.subsonic_api.get_album_by_id(album_id)
def lookup_artist(self, artist_id):
return list(
self.subsonic_api.get_artist_as_songs_as_tracks_iter(artist_id)
)
def lookup_directory(self, directory_id):
return list(
self.subsonic_api.get_recursive_dir_as_songs_as_tracks_iter(
directory_id
)
)
def lookup_playlist(self, playlist_id):
return self.subsonic_api.get_playlist_as_playlist(playlist_id).tracks
return self.subsonic_api.get_artist_by_id(artist_id)
def browse(self, browse_uri):
if browse_uri == uri.get_vdir_uri("root"):
root_vdir_names = ["rootdirs", "artists", "albums", "random"]
root_vdirs = [
self._vdirs[vdir_name] for vdir_name in root_vdir_names
]
sorted_root_vdirs = sorted(
root_vdirs, key=lambda vdir: vdir["name"]
)
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"])
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()
@ -100,9 +76,6 @@ class SubidyLibraryProvider(backend.LibraryProvider):
return self.browse_artists()
elif browse_uri == uri.get_vdir_uri("albums"):
return self.browse_albums()
elif browse_uri == uri.get_vdir_uri("random"):
return self.browse_random_songs()
else:
uri_type = uri.get_type(browse_uri)
if uri_type == uri.DIRECTORY:
@ -120,91 +93,68 @@ class SubidyLibraryProvider(backend.LibraryProvider):
return self.lookup_artist(uri.get_artist_id(lookup_uri))
if type == uri.ALBUM:
return self.lookup_album(uri.get_album_id(lookup_uri))
if type == uri.DIRECTORY:
return self.lookup_directory(uri.get_directory_id(lookup_uri))
if type == uri.SONG:
return self.lookup_song(uri.get_song_id(lookup_uri))
if type == uri.PLAYLIST:
return self.lookup_playlist(uri.get_playlist_id(lookup_uri))
def lookup(self, uri=None, uris=None):
if uris is not None:
return {uri: self.lookup_one(uri) for uri in uris}
return [self.lookup_one(uri) for uri in uris]
if uri is not None:
return self.lookup_one(uri)
return [self.lookup_one(uri)]
return None
def refresh(self, uri):
pass
def search_by_artist_album_and_track(
self, artist_name, album_name, track_name
):
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):
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.find_raw(artist_name).get("artist")
if artists is None:
return None
tracks = []
for artist in artists:
for album in self.subsonic_api.get_raw_albums(artist.get("id")):
if album_name in album.get("name"):
tracks.extend(
self.subsonic_api.get_songs_as_tracks(album.get("id"))
)
return SearchResult(tracks=tracks)
def search_by_artist(self, artist_name, exact):
result = self.subsonic_api.find_raw(artist_name)
if result is None:
return None
tracks = []
for artist in result.get("artist"):
if exact:
if not artist.get("name") == artist_name:
continue
tracks.extend(
self.subsonic_api.get_artist_as_songs_as_tracks_iter(
artist.get("id")
)
)
return SearchResult(uri=uri.get_search_uri(artist_name), tracks=tracks)
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')))
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.search_by_artist(query.get("artist")[0], exact)
if "comment" in query:
if query.get("comment")[0] == "random":
return SearchResult(
tracks=self.subsonic_api.get_random_songs_as_tracks()
)
if "any" in query:
return self.subsonic_api.find_as_search_result(query.get("any")[0])
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,14 +1,12 @@
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().__init__(*args, **kwargs)
super(SubidyPlaybackProvider, self).__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api
def translate_uri(self, translate_uri):
@ -16,6 +14,3 @@ class SubidyPlaybackProvider(backend.PlaybackProvider):
censored_url = self.subsonic_api.get_censored_song_stream_uri(song_id)
logger.debug("Loading song from subsonic with url: '%s'" % censored_url)
return self.subsonic_api.get_song_stream_uri(song_id)
def should_download(self, uri):
return True

View file

@ -1,57 +1,35 @@
import logging
from mopidy import backend
from mopidy_subidy import uri
import logging
logger = logging.getLogger(__name__)
class SubidyPlaylistsProvider(backend.PlaylistsProvider):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(SubidyPlaylistsProvider, self).__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api
self.playlists = []
self.refresh()
def as_list(self):
return self.subsonic_api.get_playlists_as_refs()
return self.playlists
def create(self, name):
result = self.subsonic_api.create_playlist_raw(name)
if result is None:
return None
playlist = result.get("playlist")
if playlist is None:
for pl in self.subsonic_api.get_playlists_as_playlists():
if pl.name == name:
playlist = pl
return playlist
else:
return self.subsonic_api.raw_playlist_to_playlist(playlist)
def delete(self, playlist_uri):
playlist_id = uri.get_playlist_id(playlist_uri)
self.subsonic_api.delete_playlist_raw(playlist_id)
def get_items(self, items_uri):
return self.subsonic_api.get_playlist_as_songs_as_refs(
uri.get_playlist_id(items_uri)
)
def lookup(self, lookup_uri):
return self.subsonic_api.get_playlist_as_playlist(
uri.get_playlist_id(lookup_uri)
)
def refresh(self):
pass
def delete(self, uri):
pass
def get_items(self, items_uri):
#logger.info('ITEMS %s: %s' % (lookup_uri, self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri))))
return self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri))
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))
def refresh(self):
self.playlists = self.subsonic_api.get_playlists_as_refs()
def save(self, playlist):
playlist_id = uri.get_playlist_id(playlist.uri)
track_ids = []
for trk in playlist.tracks:
track_ids.append(uri.get_song_id(trk.uri))
result = self.subsonic_api.save_playlist_raw(playlist_id, track_ids)
if result is None:
return None
return playlist
pass

View file

@ -1,28 +1,25 @@
import logging
import re
from urllib.parse import urlencode, urlparse
from urlparse import urlparse
import libsonic
from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track
import logging
import itertools
from mopidy.models import Track, Album, Artist, Playlist, Ref, SearchResult
import re
from mopidy_subidy import uri
logger = logging.getLogger(__name__)
RESPONSE_OK = "ok"
UNKNOWN_SONG = "Unknown Song"
UNKNOWN_ALBUM = "Unknown Album"
UNKNOWN_ARTIST = "Unknown Artist"
RESPONSE_OK = u'ok'
UNKNOWN_SONG = u'Unknown Song'
UNKNOWN_ALBUM = u'Unknown Album'
UNKNOWN_ARTIST = u'Unknown Artist'
MAX_SEARCH_RESULTS = 100
MAX_LIST_RESULTS = 500
def ref_sort_key(ref):
return ref.name
ref_sort_key = lambda ref: 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:
@ -30,517 +27,248 @@ 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, legacy_auth):
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",
appName=app_name,
legacyAuth=legacy_auth,
apiVersion=api_version,
)
self.url = url + "/rest"
parsed.path + '/rest',
legacyAuth=legacy_auth)
self.url = url + '/rest'
self.username = username
self.password = password
logger.info(
f"Connecting to subsonic server on url {url} as user {username}, "
f"API version {api_version}"
)
logger.info('Connecting to subsonic server on url %s as user %s' % (url, username))
try:
self.connection.ping()
except Exception as e:
logger.error("Unable to reach subsonic server: %s" % e)
logger.error('Unabled to reach subsonic server: %s' % e)
exit()
def get_subsonic_uri(self, view_name, params, censor=False):
di_params = {}
di_params.update(params)
di_params.update(c=self.connection.appName)
di_params.update(v=self.connection.apiVersion)
if censor:
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))
def get_song_stream_uri(self, song_id):
return self.get_subsonic_uri("stream", dict(id=song_id))
template = '%s/stream.view?id=%s&u=%s&p=%s&c=mopidy&v=1.14'
return template % (self.url, song_id, self.username, self.password)
def get_censored_song_stream_uri(self, song_id):
return self.get_subsonic_uri("stream", dict(id=song_id), True)
template = '%s/stream.view?id=%s&u=******&p=******&c=mopidy&v=1.14'
return template % (self.url, song_id)
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.search3(
query.encode("utf-8"),
MAX_SEARCH_RESULTS if not exclude_artists else 0,
0,
MAX_SEARCH_RESULTS if not exclude_albums else 0,
0,
MAX_SEARCH_RESULTS if not exclude_songs else 0,
0,
)
except Exception:
logger.warning("Connecting to subsonic failed when searching.")
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.')
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("searchResult3")
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 []
],
)
def create_playlist_raw(self, name):
try:
response = self.connection.createPlaylist(name=name)
except Exception:
logger.warning(
"Connecting to subsonic failed when creating playlist."
)
return None
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None
return response
def delete_playlist_raw(self, playlist_id):
try:
response = self.connection.deletePlaylist(playlist_id)
except Exception:
logger.warning(
"Connecting to subsonic failed when deleting playlist."
)
return None
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None
return response
def save_playlist_raw(self, playlist_id, song_ids):
try:
response = self.connection.createPlaylist(
playlist_id, songIds=song_ids
)
except Exception:
logger.warning(
"Connecting to subsonic failed when creating playlist."
)
return None
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None
return response
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 get_raw_artists(self):
try:
response = self.connection.getArtists()
except Exception:
logger.warning(
"Connecting to subsonic failed when loading list of artists."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading list of rootdirs."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading song by id."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading album by id."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading artist by id."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading list of playlists."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading playlist."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when listing content of music directory."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading list of albums."
)
except Exception as e:
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:
logger.warning(
"Connecting to subsonic failed when loading list of songs in album."
)
except Exception as e:
logger.warning('Connecting to subsonic failed when loading list of songs in album.')
return []
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
if response.get('status') != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
return []
songs = response.get("album").get("song")
songs = response.get('album').get('song')
if songs is not None:
return songs
return []
def get_raw_random_song(self, size=MAX_LIST_RESULTS):
def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS):
try:
response = self.connection.getRandomSongs(size)
except Exception:
logger.warning(
"Connecting to subsonic failed when loading ramdom song list."
)
response = self.connection.getAlbumList2(ltype=ltype, size=size)
except Exception as e:
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 []
songs = response.get("randomSongs").get("song")
if songs is not None:
return songs
return []
def get_more_albums(self, ltype, size=MAX_LIST_RESULTS, offset=0):
try:
response = self.connection.getAlbumList2(
ltype=ltype, size=size, offset=offset
)
except Exception:
logger.warning(
"Connecting to subsonic failed when loading album list."
)
return []
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return []
albums = response.get("albumList2").get("album")
albums = response.get('albumList2').get('album')
if albums is not None:
return albums
return []
def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS):
"""
Subsonic servers don't offer any way to retrieve the total number
of albums to get, and the spec states that the max number returned
for `getAlbumList2` is 500. To get all the albums, we make a
`getAlbumList2` request each time the response contains 500 albums. If
it does not, we assume we have all the albums and return them.
"""
offset = 0
total = []
albums = self.get_more_albums(ltype, size, offset)
total = albums
while len(albums) == size:
offset = offset + size
albums = self.get_more_albums(ltype, size, offset)
total = total + albums
return total
def get_albums_as_refs(self, artist_id=None):
albums = (
self.get_raw_album_list("alphabeticalByName")
if artist_id is None
else self.get_raw_albums(artist_id)
)
albums = (self.get_raw_album_list('alphabeticalByName') if artist_id is None else self.get_raw_albums(artist_id))
return [self.raw_album_to_ref(album) for album in albums]
def get_albums_as_albums(self, artist_id):
return [
self.raw_album_to_album(album)
for album in self.get_raw_albums(artist_id)
]
return [self.raw_album_to_album(album) for album in self.get_raw_albums(artist_id)]
def get_songs_as_refs(self, album_id):
return [
self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)
]
return [self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)]
def get_songs_as_tracks(self, album_id):
return [
self.raw_song_to_track(song)
for song in self.get_raw_songs(album_id)
]
return [self.raw_song_to_track(song) for song in self.get_raw_songs(album_id)]
def get_artists_as_refs(self):
return [
self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()
]
return [self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()]
def get_rootdirs_as_refs(self):
return [
self.raw_directory_to_ref(rootdir)
for rootdir in self.get_raw_rootdirs()
]
return [self.raw_directory_to_ref(rootdir) for rootdir in self.get_raw_rootdirs()]
def get_diritems_as_refs(self, directory_id):
return [
(
self.raw_directory_to_ref(diritem)
if diritem.get("isDir")
else self.raw_song_to_ref(diritem)
)
for diritem in self.get_raw_dir(directory_id)
]
def get_random_songs_as_refs(self):
return [
self.raw_song_to_ref(song) for song in self.get_raw_random_song(75)
]
def get_random_songs_as_tracks(self):
return [
self.raw_song_to_track(song) for song in self.get_raw_random_song()
]
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))
@ -549,130 +277,85 @@ 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")]
def get_artist_as_songs_as_tracks_iter(self, artist_id):
albums = self.get_raw_albums(artist_id)
if albums is None:
return
for album in albums:
for song in self.get_raw_songs(album.get("id")):
yield self.raw_song_to_track(song)
def get_recursive_dir_as_songs_as_tracks_iter(self, directory_id):
diritems = self.get_raw_dir(directory_id)
if diritems is None:
return
for item in diritems:
if item.get("isDir"):
yield from self.get_recursive_dir_as_songs_as_tracks_iter(
item.get("id")
)
else:
yield self.raw_song_to_track(item)
return [self.raw_song_to_ref(song) for song in playlist.get('entry')]
def raw_song_to_ref(self, song):
if song is None:
return None
return Ref.track(
name=song.get("title") or UNKNOWN_SONG,
uri=uri.get_song_uri(song.get("id")),
)
name=song.get('title') or UNKNOWN_SONG,
uri=uri.get_song_uri(song.get('id')))
def raw_song_to_track(self, song):
if song is None:
return None
return Track(
name=song.get("title") or UNKNOWN_SONG,
uri=uri.get_song_uri(song.get("id")),
bitrate=song.get("bitRate"),
track_no=int(song.get("track")) if song.get("track") else None,
date=str(song.get("year")) or "none",
genre=song.get("genre"),
length=int(song.get("duration")) * 1000
if song.get("duration")
else None,
disc_no=int(song.get("discNumber"))
if song.get("discNumber")
else None,
artists=[
Artist(
name=song.get("artist"),
uri=uri.get_artist_uri(song.get("artistId")),
)
],
name=song.get('title') or UNKNOWN_SONG,
uri=uri.get_song_uri(song.get('id')),
bitrate=song.get('bitRate'),
track_no=int(song.get('track')) if song.get('track') else None,
date=str(song.get('year')) or 'none',
genre=song.get('genre'),
length=int(song.get('duration')) * 1000 if song.get('duration') else None,
disc_no=int(song.get('discNumber')) if song.get('discNumber') else None,
artists=[Artist(
name=song.get('artist'),
uri=uri.get_artist_uri(song.get('artistId')))],
album=Album(
name=song.get("album"),
uri=uri.get_album_uri(song.get("albumId")),
),
)
name=song.get('album'),
uri=uri.get_album_uri(song.get('albumId'))))
def raw_album_to_ref(self, album):
if album is None:
return None
return Ref.album(
name=album.get("title") or album.get("name") or UNKNOWN_ALBUM,
uri=uri.get_album_uri(album.get("id")),
)
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM,
uri=uri.get_album_uri(album.get('id')))
def raw_album_to_album(self, album):
if album is None:
return None
return Album(
name=album.get("title") or album.get("name") or UNKNOWN_ALBUM,
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,
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,110 +1,87 @@
import re
SONG = "song"
ARTIST = "artist"
PLAYLIST = "playlist"
ALBUM = "album"
DIRECTORY = "directory"
VDIR = "vdir"
PREFIX = "subidy"
SEARCH = "search"
RANDOM = "random"
regex = re.compile(r"(\w+?):(\w+?)(?::|$)(.+?)?$")
SONG = 'song'
ARTIST = 'artist'
PLAYLIST = 'playlist'
ALBUM = 'album'
DIRECTORY = 'directory'
VDIR = 'vdir'
PREFIX = 'subidy'
SEARCH = 'search'
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 f"{PREFIX}:{type}:{id}"
return u'%s:%s:%s' % (PREFIX, type, id)
def get_artist_uri(id):
return get_type_uri(ARTIST, id)
def get_album_uri(id):
return get_type_uri(ALBUM, id)
def get_song_uri(id):
return get_type_uri(SONG, id)
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)

View file

@ -1,17 +0,0 @@
[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"

View file

@ -1,86 +0,0 @@
[metadata]
name = Mopidy-Subidy
version = 1.1.0
url = https://github.com/Prior99/mopidy-subidy
author = prior99
author_email = fgnodtke@cronosx.de
license = BSD-3-Clause
license_file = LICENSE
description = Subsonic extension for Mopidy
long_description = file: README.rst
classifiers =
Environment :: No Input/Output (Daemon)
Intended Audience :: End Users/Desktop
License :: OSI Approved :: BSD License
Operating System :: OS Independent
Programming Language :: Python :: 3
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Topic :: Multimedia :: Sound/Audio :: Players
[options]
zip_safe = False
include_package_data = True
packages = find:
python_requires = >= 3.7
install_requires =
Mopidy >= 3.0.0
Pykka >= 2.0.1
setuptools
py-sonic >= 0.7.7
[options.extras_require]
lint =
black
check-manifest
flake8
flake8-bugbear
flake8-import-order
isort[pyproject]
release =
twine
wheel
test =
pytest
pytest-cov
dev =
%(lint)s
%(release)s
%(test)s
[options.packages.find]
exclude =
tests
tests.*
[options.entry_points]
mopidy.ext =
subidy = mopidy_subidy:SubidyExtension
[flake8]
application-import-names = mopidy_subidy, tests
max-line-length = 80
exclude = .git, .tox, build
select =
# Regular flake8 rules
C, E, F, W
# flake8-bugbear rules
B
# B950: line too long (soft speed limit)
B950
# pep8-naming rules
N
ignore =
# E203: whitespace before ':' (not PEP8 compliant)
E203
# E501: line too long (replaced by B950)
E501
# W503: line break before binary operator (not PEP8 compliant)
W503
# B305: .next() is not a thing on Python 3 (used by playback controller)
B305

View file

@ -1,3 +1,43 @@
from setuptools import setup
from __future__ import unicode_literals
setup()
import re
from setuptools import setup, find_packages
def get_version(filename):
content = open(filename).read()
metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content))
return metadata['version']
setup(
name='Mopidy-Subidy',
version=get_version('mopidy_subidy/__init__.py'),
url='http://github.com/prior99/mopidy-subidy/',
license='BSD-3-Clause',
author='prior99',
author_email='fgnodtke@cronosx.de',
description='Improved Subsonic extension for Mopidy',
long_description=open('README.md').read(),
packages=find_packages(exclude=['tests', 'tests.*']),
zip_safe=False,
include_package_data=True,
install_requires=[
'setuptools',
'Mopidy >= 2.0',
'py-sonic >= 0.6.1',
'Pykka >= 1.1'
],
entry_points={
b'mopidy.ext': [
'subidy = mopidy_subidy:SubidyExtension',
],
},
classifiers=[
'Environment :: No Input/Output (Daemon)',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: BSD 3-Clause',
'Operating System :: OS Independent',
'Programming Language :: Python :: 2',
'Topic :: Multimedia :: Sound/Audio :: Players'
]
)

View file

View file

@ -1,23 +0,0 @@
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
View file

@ -1,23 +0,0 @@
[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