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 *.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 LICENSE
include MANIFEST.in include MANIFEST.in
include pyproject.toml include README.md
include tox.ini include mopidy_subidy/ext.conf
recursive-include .circleci *
recursive-include .github *
include mopidy_*/ext.conf
recursive-include tests *.py
recursive-include tests/data *

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): 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):
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): def get_config_schema(self):
schema = super().get_config_schema() schema = super(SubidyExtension, self).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)
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,25 +1,17 @@
import pykka
import mopidy_subidy
from mopidy import backend
from mopidy_subidy import library, playback, playlists, subsonic_api from mopidy_subidy import library, playback, playlists, subsonic_api
from mopidy import backend
import pykka
class SubidyBackend(pykka.ThreadingActor, backend.Backend): class SubidyBackend(pykka.ThreadingActor, backend.Backend):
def __init__(self, config, audio): def __init__(self, config, audio):
super().__init__() super(SubidyBackend, self).__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, legacy_auth=subidy_config['legacy_auth'])
legacy_auth=subidy_config["legacy_auth"],
api_version=subidy_config["api_version"],
)
self.library = library.SubidyLibraryProvider(backend=self) self.library = library.SubidyLibraryProvider(backend=self)
self.playback = playback.SubidyPlaybackProvider( self.playback = playback.SubidyPlaybackProvider(audio=audio, backend=self)
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

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

View file

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

View file

@ -1,14 +1,12 @@
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().__init__(*args, **kwargs) super(SubidyPlaybackProvider, self).__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):
@ -16,6 +14,3 @@ class SubidyPlaybackProvider(backend.PlaybackProvider):
censored_url = self.subsonic_api.get_censored_song_stream_uri(song_id) censored_url = self.subsonic_api.get_censored_song_stream_uri(song_id)
logger.debug("Loading song from subsonic with url: '%s'" % censored_url) logger.debug("Loading song from subsonic with url: '%s'" % censored_url)
return self.subsonic_api.get_song_stream_uri(song_id) 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 import backend
from mopidy_subidy import uri from mopidy_subidy import uri
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().__init__(*args, **kwargs) super(SubidyPlaylistsProvider, self).__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()
def as_list(self): def as_list(self):
return self.subsonic_api.get_playlists_as_refs() return self.playlists
def create(self, name): 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 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): def save(self, playlist):
playlist_id = uri.get_playlist_id(playlist.uri) pass
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

View file

@ -1,28 +1,25 @@
import logging from urlparse import urlparse
import re
from urllib.parse import urlencode, urlparse
import libsonic 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 from mopidy_subidy import uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RESPONSE_OK = "ok" RESPONSE_OK = u'ok'
UNKNOWN_SONG = "Unknown Song" UNKNOWN_SONG = u'Unknown Song'
UNKNOWN_ALBUM = "Unknown Album" UNKNOWN_ALBUM = u'Unknown Album'
UNKNOWN_ARTIST = "Unknown Artist" UNKNOWN_ARTIST = u'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:
@ -30,517 +27,248 @@ 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():
class SubsonicApi: def __init__(self, url, username, password, legacy_auth):
def __init__(
self, url, username, password, app_name, legacy_auth, api_version
):
parsed = urlparse(url) parsed = urlparse(url)
self.port = ( self.port = parsed.port if parsed.port else \
parsed.port 443 if parsed.scheme == 'https' else 80
if parsed.port base_url = parsed.scheme + '://' + parsed.hostname
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, legacyAuth=legacy_auth)
legacyAuth=legacy_auth, self.url = url + '/rest'
apiVersion=api_version,
)
self.url = url + "/rest"
self.username = username self.username = username
self.password = password self.password = password
logger.info( logger.info('Connecting to subsonic server on url %s as user %s' % (url, username))
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('Unabled to reach subsonic server: %s' % e)
exit() 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): 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): 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( def find_raw(self, query, exclude_artists=False, exclude_albums=False, exclude_songs=False):
self,
query,
exclude_artists=False,
exclude_albums=False,
exclude_songs=False,
):
try: try:
response = self.connection.search3( response = self.connection.search2(
query.encode("utf-8"), query.encode('utf-8'),
MAX_SEARCH_RESULTS if not exclude_artists else 0, MAX_SEARCH_RESULTS if not exclude_artists else 0, 0,
0, MAX_SEARCH_RESULTS if not exclude_albums else 0, 0,
MAX_SEARCH_RESULTS if not exclude_albums else 0, MAX_SEARCH_RESULTS if not exclude_songs else 0, 0)
0, except Exception as e:
MAX_SEARCH_RESULTS if not exclude_songs else 0, logger.warning('Connecting to subsonic failed when searching.')
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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return response.get("searchResult3") return response.get('searchResult2')
def find_as_search_result( def find_as_search_result(self, query, exclude_artists=False, exclude_albums=False, exclude_songs=False):
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=[ artists=[self.raw_artist_to_artist(artist) for artist in result.get('artist') or []],
self.raw_artist_to_artist(artist) albums=[self.raw_album_to_album(album) for album in result.get('album') or []],
for artist in result.get("artist") or [] tracks=[self.raw_song_to_track(song) for song in result.get('song') or []])
],
albums=[
self.raw_album_to_album(album)
for album in result.get("album") or []
],
tracks=[
self.raw_song_to_track(song)
for song in result.get("song") or []
],
)
def create_playlist_raw(self, name):
try:
response = self.connection.createPlaylist(name=name)
except Exception:
logger.warning(
"Connecting to subsonic failed when creating playlist."
)
return None
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None
return response
def delete_playlist_raw(self, playlist_id):
try:
response = self.connection.deletePlaylist(playlist_id)
except Exception:
logger.warning(
"Connecting to subsonic failed when deleting playlist."
)
return None
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None
return response
def save_playlist_raw(self, playlist_id, song_ids):
try:
response = self.connection.createPlaylist(
playlist_id, songIds=song_ids
)
except Exception:
logger.warning(
"Connecting to subsonic failed when creating playlist."
)
return None
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None
return response
def get_raw_artists(self): def get_raw_artists(self):
try: try:
response = self.connection.getArtists() response = self.connection.getArtists()
except Exception: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading list of artists.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"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 = [ artists = [artist for letter in letters for artist in letter.get('artist') or []]
artist
for letter in letters
for artist in letter.get("artist") or []
]
return artists return artists
logger.warning( logger.warning('Subsonic does not seem to have any artists in it\'s library.')
"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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading list of rootdirs.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"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 = [ artists = [artist for letter in letters for artist in letter.get('artist') or []]
artist
for letter in letters
for artist in letter.get("artist") or []
]
return artists return artists
logger.warning( logger.warning('Subsonic does not seem to have any rootdirs in its library.')
"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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading song by id.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return ( return self.raw_song_to_track(response.get('song')) if response.get('song') is not None else None
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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading album by id.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return ( return self.raw_album_to_album(response.get('album')) if response.get('album') is not None else None
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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading artist by id.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return ( return self.raw_artist_to_artist(response.get('artist')) if response.get('artist') is not None else None
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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading list of playlists.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"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( logger.warning('Subsonic does not seem to have any playlists in it\'s library.')
"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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading playlist.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when listing content of music directory.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading list of albums.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"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( return sorted(albums, key=lambda album: string_nums_nocase_sort_key(album['name']))
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: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading list of songs in album.')
"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( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"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 []
def get_raw_random_song(self, size=MAX_LIST_RESULTS): def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS):
try: try:
response = self.connection.getRandomSongs(size) response = self.connection.getAlbumList2(ltype=ltype, size=size)
except Exception: except Exception as e:
logger.warning( logger.warning('Connecting to subsonic failed when loading album list.')
"Connecting to subsonic failed when loading ramdom song list."
)
return [] return []
if response.get("status") != RESPONSE_OK: if response.get('status') != RESPONSE_OK:
logger.warning( logger.warning('Got non-okay status code from subsonic: %s' % response.get('status'))
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
songs = response.get("randomSongs").get("song") albums = response.get('albumList2').get('album')
if songs is not None:
return songs
return []
def get_more_albums(self, ltype, size=MAX_LIST_RESULTS, offset=0):
try:
response = self.connection.getAlbumList2(
ltype=ltype, size=size, offset=offset
)
except Exception:
logger.warning(
"Connecting to subsonic failed when loading album list."
)
return []
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return []
albums = response.get("albumList2").get("album")
if albums is not None: if albums is not None:
return albums return albums
return [] 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): def get_albums_as_refs(self, artist_id=None):
albums = ( albums = (self.get_raw_album_list('alphabeticalByName') if artist_id is None else self.get_raw_albums(artist_id))
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 [ return [self.raw_album_to_album(album) for album in self.get_raw_albums(artist_id)]
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 [ return [self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)]
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 [ return [self.raw_song_to_track(song) for song in self.get_raw_songs(album_id)]
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 [ return [self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()]
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 [ return [self.raw_directory_to_ref(rootdir) for rootdir in self.get_raw_rootdirs()]
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 [ 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)]
(
self.raw_directory_to_ref(diritem)
if diritem.get("isDir")
else self.raw_song_to_ref(diritem)
)
for diritem in self.get_raw_dir(directory_id)
]
def get_random_songs_as_refs(self):
return [
self.raw_song_to_ref(song) for song in self.get_raw_random_song(75)
]
def get_random_songs_as_tracks(self):
return [
self.raw_song_to_track(song) for song in self.get_raw_random_song()
]
def get_artists_as_artists(self): def get_artists_as_artists(self):
return [ return [self.raw_artist_to_artist(artist) for artist in self.get_raw_artists()]
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 [ return [self.raw_playlist_to_ref(playlist) for playlist in self.get_raw_playlists()]
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 [ return [self.raw_playlist_to_playlist(playlist) for playlist in self.get_raw_playlists()]
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))
@ -549,130 +277,85 @@ 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):
albums = self.get_raw_albums(artist_id)
if albums is None:
return
for album in albums:
for song in self.get_raw_songs(album.get("id")):
yield self.raw_song_to_track(song)
def get_recursive_dir_as_songs_as_tracks_iter(self, directory_id):
diritems = self.get_raw_dir(directory_id)
if diritems is None:
return
for item in diritems:
if item.get("isDir"):
yield from self.get_recursive_dir_as_songs_as_tracks_iter(
item.get("id")
)
else:
yield self.raw_song_to_track(item)
def raw_song_to_ref(self, song): def raw_song_to_ref(self, song):
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 length=int(song.get('duration')) * 1000 if song.get('duration') else None,
if song.get("duration") disc_no=int(song.get('discNumber')) if song.get('discNumber') else None,
else None, artists=[Artist(
disc_no=int(song.get("discNumber")) name=song.get('artist'),
if song.get("discNumber") uri=uri.get_artist_uri(song.get('artistId')))],
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"), 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 = ( tracks = [self.raw_song_to_track(song) for song in entries] if entries is not None else None
[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,110 +1,87 @@
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'
RANDOM = "random"
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 ( return is_type_result_valid(result) and result.group(1) == PREFIX and result.group(2) == type
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 f"{PREFIX}:{type}:{id}" return u'%s:%s:%s' % (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)

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