Compare commits

..

70 commits

Author SHA1 Message Date
ebb2dab571
add pkbuild 2025-02-05 18:55:01 +01:00
62429f22bd Update mopidy_subidy/playback.py 2025-02-04 16:41:22 +00:00
vincent
4bc641e95a resolv conflict 2021-01-14 17:49:49 +01:00
vincent
5ce65f0a08 change argument 2021-01-14 17:31:05 +01:00
vincent
b5e0de1e4d correct warning 2020-11-24 14:53:17 +01:00
Frederick
e8493923c1 Update version 2020-11-19 09:14:44 +01:00
Frederick
32f74f28e9 Merge branch 'master' of https://github.com/brynedwards/mopidy-subidy 2020-11-19 09:08:10 +01:00
Frederick
cf588481c6 Add note about maintenance 2020-11-19 09:06:50 +01:00
vincent
876bec6711 correct conflict 2020-11-15 10:21:08 +01:00
vincent
7064dd9e50 reformating 2020-11-03 20:38:01 +01:00
vincent
81a62cdca4 add random mode in browse and search 2020-11-03 20:36:28 +01:00
vincent
1bc9e35d83 add exact to search artist 2020-11-02 23:12:39 +01:00
vincent
6857653cdb modifyng search_by_artist_and_album 2020-11-02 21:13:27 +01:00
vincent
511a101c2c modify artist search to retur all artist track 2020-11-02 19:54:41 +01:00
vincent
f7090127fe black reformating 2020-11-02 16:34:21 +01:00
vincent
31023236ae correct formatting 2020-11-02 16:25:57 +01:00
vincent
eff25672d9 improve research by artist 2020-11-02 16:08:50 +01:00
Bryn Edwards
584209c134 comment 2020-03-25 08:03:46 +00:00
Bryn Edwards
ee3e36408d formatting 2020-03-14 18:57:55 +00:00
Bryn Edwards
713845090c Make repeated getAlbumList2 requests with offset to get all albums 2020-03-14 18:43:53 +00:00
Frederick
89093e7064 Release 1.0.0 2020-03-13 13:35:28 +01:00
Frederick Gnodtke
77f86bd2ed
Merge pull request #32 from jodal/python3
Modernize extension
2020-03-13 13:28:05 +01:00
Stein Magnus Jodal
bd2306a5fb docs: Add badges, install instructions, etc 2020-03-08 21:27:04 +01:00
Stein Magnus Jodal
f421bd2a90 docs: Add changelog 2020-03-08 21:15:39 +01:00
Stein Magnus Jodal
128ba8173f Fix all flake8 warnings 2020-03-08 12:43:10 +01:00
Stein Magnus Jodal
73b5f9bcc5 Use pkg_resources to read version 2020-03-08 12:33:23 +01:00
Stein Magnus Jodal
a7fdc9f436 Replace os.path with pathlib 2020-03-08 12:32:18 +01:00
Stein Magnus Jodal
cd22b5f694 Sort imports with isort 2020-03-08 12:31:00 +01:00
Stein Magnus Jodal
1b04266d92 Format with Black 2020-03-08 12:30:34 +01:00
Stein Magnus Jodal
b067352a00 Run pyupgrade to Python 3.7+ 2020-03-08 12:30:14 +01:00
Stein Magnus Jodal
008527f115 Update project to match cookiecutter-mopidy-ext 2020-03-08 12:29:05 +01:00
Frederick Gnodtke
35340c5c69
Merge pull request #29 from aagallag/python3
Add support for python3
2020-02-01 11:18:36 +01:00
Aaron B. Gallagher
e27563edaf Add support for python3
Mopidy no longer supports Python 2.7.
These changes remove support for Python 2.7 in subidy as well
2020-01-29 20:31:52 -08:00
Frederick Gnodtke
a2b22cb793
Merge pull request #25 from jonathanchristison/master
Remove version range for py-sonic
2018-10-06 08:35:57 +02:00
Jonathan Christison
521cdf8002 Remove version range for py-sonic
py-sonic stopped supporting python2 > 0.6.2
Using pip to install results in an error if this is not set
2018-08-08 23:01:25 +01:00
Frederick Gnodtke
8d7cd3ff01 Merge pull request #20 from hhm0/appname_extname
extension name as subsonic API client name
2017-08-14 07:53:36 +02:00
Frederick Gnodtke
70b8245fd0 Merge pull request #19 from hhm0/writable_playlists
Add playlist editing functionality
2017-08-14 07:51:01 +02:00
hhm
f95af9c977 B"H use mopidy extension name as subsonic API app name 2017-08-13 03:17:03 -04:00
hhm
3f8a3d709e Merge branch 'master' of https://www.github.com/Prior99/mopidy-subidy into writable_playlists 2017-06-19 17:56:12 -04:00
Frederick
da58978427 Merge branch 'hhm0-album_obj_numtracks' 2017-06-12 07:31:04 +02:00
hhm
8f5d3e0216 B"H shuffle code a bit to make it clearer 2017-06-04 22:52:50 -04:00
hhm
a7eef84b2a Merge branch 'master' of https://www.github.com/Prior99/mopidy-subidy into writable_playlists 2017-06-04 22:45:01 -04:00
hhm
ff66d4fa40 Merge branch 'master' of https://www.github.com/Prior99/mopidy-subidy into album_obj_numtracks 2017-06-04 22:36:29 -04:00
hhm
f70ec43cfb B"H readme item merge 2017-05-20 23:38:50 -04:00
hhm
a7039ba53c B"H add readme item supported 2017-05-20 23:35:58 -04:00
hhm
7592036c7f B"H rewrite playlist from scratch instead of adding more tracks 2017-05-19 02:18:42 -04:00
hhm
364352a765 B"H get list of playlists immediately instead of caching them 2017-05-18 23:45:46 -04:00
hhm
a2f23c2095 B"H use playlists instead of playlist refs 2017-05-18 23:42:18 -04:00
hhm
de69905c54 B"H add playlist writing code 2017-05-18 23:25:44 -04:00
hhm
6a87ebe8e2 B"H add track count to Album model 2017-05-17 02:10:58 -04:00
Jolny
fb64508673 Edited README, added API version instructions 2017-05-15 00:14:16 +02:00
Jolny
f8e1311866 Added API version setting for backward compatability 2017-05-15 00:12:49 +02:00
Frederick Gnodtke
8226a35bc2 Merge pull request #12 from hhm0/lookup_tracks
Lookup items as tracks
2017-05-04 07:26:58 +02:00
Frederick Gnodtke
f777b01f72 Merge pull request #13 from hhm0/playlist_songs
Fix typo to allow playlist song listing
2017-05-04 07:26:51 +02:00
hhm
aec8b02484 B"H lookup playlists - rebased 2017-03-27 16:48:54 -04:00
hhm
1f19ffcb75 B"H lookup_song return list, add search query dir uri - rebased 2017-03-27 16:48:48 -04:00
hhm
64938a8c7e B"H lookup return subdir songs too, artist lookup fn to iter - rebase 2017-03-27 16:48:08 -04:00
hhm
634efc4de4 B"H add library lookup playlist TODO - rebase 2017-03-27 16:48:01 -04:00
hhm
5bfe185ef0 B"H use consistent name (same as playlist_as_songs) - rebase 2017-03-27 16:47:55 -04:00
hhm
127cd030d8 B"H lookup directory - rebased 2017-03-27 16:47:48 -04:00
hhm
9ac788b69b B"H lookup artists - rebase 2017-03-27 16:47:38 -04:00
hhm
7f9c685d1d B"H add album songs retrieval - rebased 2017-03-27 16:46:53 -04:00
hhm
fe444091a6 B"H playlist get items fix method call 2017-03-27 15:49:18 -04:00
Frederick
ad1d862d21 Merge branch 'urlencode_uris' of https://github.com/hhm0/mopidy-subidy into hhm0-urlencode_uris 2017-03-23 07:39:40 +01:00
hhm
f43711a346 B"H url encoding: fix var name 2017-03-22 17:49:10 -04:00
hhm
9e8116713c Merge branch 'master' of https://www.github.com/Prior99/mopidy-subidy into urlencode_uris 2017-03-22 16:07:37 -04:00
Frederick
8ab48138c3 Merge branch 'urlencode_uris' of https://github.com/hhm0/mopidy-subidy into hhm0-urlencode_uris 2017-03-22 08:59:02 +01:00
Frederick
76ae3d3ec5 Merge branch 'hhm0-to_browse' 2017-03-22 08:58:24 +01:00
hhm
e004816794 B"H split subsonic uri params to make clearer 2017-03-18 23:51:18 -04:00
hhm
314002237d B"H urlencode subsonic api urls 2017-03-17 02:21:27 -04:00
21 changed files with 1129 additions and 340 deletions

51
.circleci/config.yml Normal file
View file

@ -0,0 +1,51 @@
version: 2.1
orbs:
codecov: codecov/codecov@1.0.5
workflows:
version: 2
test:
jobs:
- py38
- py37
- black
- check-manifest
- flake8
jobs:
py38: &test-template
docker:
- image: mopidy/ci-python:3.8
steps:
- checkout
- restore_cache:
name: Restoring tox cache
key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }}
- run:
name: Run tests
command: |
tox -e $CIRCLE_JOB -- \
--junit-xml=test-results/pytest/results.xml \
--cov-report=xml
- save_cache:
name: Saving tox cache
key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }}
paths:
- ./.tox
- ~/.cache/pip
- codecov/upload:
file: coverage.xml
- store_test_results:
path: test-results
py37:
<<: *test-template
docker:
- image: mopidy/ci-python:3.7
black: *test-template
check-manifest: *test-template
flake8: *test-template

13
.gitignore vendored
View file

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

111
CHANGELOG.rst Normal file
View file

@ -0,0 +1,111 @@
*********
Changelog
*********
v1.0.0 (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,4 +1,15 @@
include *.py
include *.rst
include .mailmap
include LICENSE include LICENSE
include MANIFEST.in include MANIFEST.in
include README.md include pyproject.toml
include mopidy_subidy/ext.conf include tox.ini
recursive-include .circleci *
recursive-include .github *
include mopidy_*/ext.conf
recursive-include tests *.py
recursive-include tests/data *

35
PKGBUILD Normal file
View file

@ -0,0 +1,35 @@
# 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"
}

View file

@ -1,39 +0,0 @@
# Mopidy Subidy
A subsonic backend for mopidy using [py-sub](https://github.com/crustymonkey/py-sonic).
## Configuration
Add a section similiar to the following to your mopidy configuration:
```ini
[subidy]
enabled=True
url=https://path.to/your/subsonic/server
username=subsonic_username
password=your_secret_password
legacy_auth=(optional; setting to yes may solve some connection errors)
```
## 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

81
README.rst Normal file
View file

@ -0,0 +1,81 @@
*************
Mopidy-Subidy
*************
.. image:: https://img.shields.io/pypi/v/Mopidy-Subidy
:target: https://pypi.org/project/Mopidy-Subidy/
:alt: Latest PyPI version
.. image:: https://img.shields.io/circleci/build/gh/Prior99/mopidy-subidy
:target: https://circleci.com/gh/Prior99/mopidy-subidy
:alt: CircleCI build status
.. image:: https://img.shields.io/codecov/c/gh/Prior99/mopidy-subidy
:target: https://codecov.io/gh/Prior99/mopidy-subidy
:alt: Test coverage
**This library is actively looking for maintainers to help out as I do not have the time or need to maintain this anymore. Please contact me if you feel that you could maintain this.**
A Subsonic backend for Mopidy using `py-sonic
<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,30 +1,31 @@
from __future__ import unicode_literals import pathlib
import os import pkg_resources
from mopidy import ext, config from mopidy import config, ext
__version__ = '0.2.1' __version__ = pkg_resources.get_distribution("Mopidy-Subidy").version
class SubidyExtension(ext.Extension): class SubidyExtension(ext.Extension):
dist_name = 'Mopidy-Subidy' dist_name = "Mopidy-Subidy"
ext_name = 'subidy' ext_name = "subidy"
version = __version__ version = __version__
def get_default_config(self): def get_default_config(self):
conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(pathlib.Path(__file__).parent / "ext.conf")
return config.read(conf_file)
def get_config_schema(self): def get_config_schema(self):
schema = super(SubidyExtension, self).get_config_schema() schema = super().get_config_schema()
schema['url'] = config.String() schema["url"] = config.String()
schema['username'] = config.String() schema["username"] = config.String()
schema['password'] = config.Secret() schema["password"] = config.Secret()
schema['legacy_auth'] = config.Boolean(optional=True) schema["legacy_auth"] = config.Boolean(optional=True)
schema["api_version"] = config.String(optional=True)
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,17 +1,25 @@
from mopidy_subidy import library, playback, playlists, subsonic_api
from mopidy import backend
import pykka import pykka
import mopidy_subidy
from mopidy import backend
from mopidy_subidy import library, playback, playlists, subsonic_api
class SubidyBackend(pykka.ThreadingActor, backend.Backend): class SubidyBackend(pykka.ThreadingActor, backend.Backend):
def __init__(self, config, audio): def __init__(self, config, audio):
super(SubidyBackend, self).__init__() super().__init__()
subidy_config = config['subidy'] subidy_config = config["subidy"]
self.subsonic_api = subsonic_api.SubsonicApi( self.subsonic_api = subsonic_api.SubsonicApi(
url=subidy_config['url'], url=subidy_config["url"],
username=subidy_config['username'], username=subidy_config["username"],
password=subidy_config['password'], password=subidy_config["password"],
legacy_auth=subidy_config['legacy_auth']) app_name=mopidy_subidy.SubidyExtension.dist_name,
legacy_auth=subidy_config["legacy_auth"],
api_version=subidy_config["api_version"],
)
self.library = library.SubidyLibraryProvider(backend=self) self.library = library.SubidyLibraryProvider(backend=self)
self.playback = playback.SubidyPlaybackProvider(audio=audio, backend=self) self.playback = playback.SubidyPlaybackProvider(
audio=audio, backend=self
)
self.playlists = playlists.SubidyPlaylistsProvider(backend=self) self.playlists = playlists.SubidyPlaylistsProvider(backend=self)
self.uri_schemes = ['subidy'] self.uri_schemes = ["subidy"]

View file

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

View file

@ -1,10 +1,12 @@
from mopidy import backend, models import logging
from mopidy import backend
from mopidy.models import Ref, SearchResult from mopidy.models import Ref, SearchResult
from mopidy_subidy import uri from mopidy_subidy import uri
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SubidyLibraryProvider(backend.LibraryProvider): class SubidyLibraryProvider(backend.LibraryProvider):
def __create_vdirs(): def __create_vdirs():
vdir_templates = [ vdir_templates = [
@ -12,6 +14,7 @@ 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`,
@ -20,7 +23,7 @@ class SubidyLibraryProvider(backend.LibraryProvider):
for template in vdir_templates: for template in vdir_templates:
vdir = template.copy() vdir = template.copy()
vdir.update(uri=uri.get_vdir_uri(vdir["id"])) vdir.update(uri=uri.get_vdir_uri(vdir["id"]))
vdirs[template['id']] = vdir vdirs[template["id"]] = vdir
return vdirs return vdirs
_vdirs = __create_vdirs() _vdirs = __create_vdirs()
@ -28,16 +31,14 @@ class SubidyLibraryProvider(backend.LibraryProvider):
def __raw_vdir_to_ref(vdir): def __raw_vdir_to_ref(vdir):
if vdir is None: if vdir is None:
return None return None
return Ref.directory( return Ref.directory(name=vdir["name"], uri=vdir["uri"])
name=vdir['name'],
uri=vdir['uri'])
root_directory = __raw_vdir_to_ref(_vdirs['root']) root_directory = __raw_vdir_to_ref(_vdirs["root"])
_raw_vdir_to_ref = staticmethod(__raw_vdir_to_ref) _raw_vdir_to_ref = staticmethod(__raw_vdir_to_ref)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SubidyLibraryProvider, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api self.subsonic_api = self.backend.subsonic_api
def browse_songs(self, album_id): def browse_songs(self, album_id):
@ -52,23 +53,46 @@ 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):
return self.subsonic_api.get_song_by_id(song_id) song = self.subsonic_api.get_song_by_id(song_id)
if song is None:
return []
else:
return [song]
def lookup_album(self, album_id): def lookup_album(self, album_id):
return self.subsonic_api.get_album_by_id(album_id) return self.subsonic_api.get_songs_as_tracks(album_id)
def lookup_artist(self, artist_id): def lookup_artist(self, artist_id):
return self.subsonic_api.get_artist_by_id(artist_id) return list(
self.subsonic_api.get_artist_as_songs_as_tracks_iter(artist_id)
)
def lookup_directory(self, directory_id):
return list(
self.subsonic_api.get_recursive_dir_as_songs_as_tracks_iter(
directory_id
)
)
def lookup_playlist(self, playlist_id):
return self.subsonic_api.get_playlist_as_playlist(playlist_id).tracks
def browse(self, browse_uri): def browse(self, browse_uri):
if browse_uri == uri.get_vdir_uri('root'): if browse_uri == uri.get_vdir_uri("root"):
root_vdir_names = ["rootdirs", "artists", "albums"] root_vdir_names = ["rootdirs", "artists", "albums", "random"]
root_vdirs = [self._vdirs[vdir_name] for vdir_name in root_vdir_names] root_vdirs = [
sorted_root_vdirs = sorted(root_vdirs, key=lambda vdir: vdir["name"]) self._vdirs[vdir_name] for vdir_name in root_vdir_names
]
sorted_root_vdirs = sorted(
root_vdirs, key=lambda vdir: vdir["name"]
)
return [self._raw_vdir_to_ref(vdir) for vdir in sorted_root_vdirs] return [self._raw_vdir_to_ref(vdir) for vdir in sorted_root_vdirs]
elif browse_uri == uri.get_vdir_uri("rootdirs"): elif browse_uri == uri.get_vdir_uri("rootdirs"):
return self.browse_rootdirs() return self.browse_rootdirs()
@ -76,6 +100,9 @@ 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:
@ -93,68 +120,91 @@ 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 [self.lookup_one(uri) for uri in uris] return {uri: self.lookup_one(uri) for uri in uris}
if uri is not None: if uri is not None:
return [self.lookup_one(uri)] return self.lookup_one(uri)
return None return None
def refresh(self, uri): def refresh(self, uri):
pass pass
def search_uri(self, query): def search_by_artist_album_and_track(
type = uri.get_type(lookup_uri) self, artist_name, album_name, track_name
if type == uri.ARTIST: ):
artist = self.lookup_artist(uri.get_artist_id(lookup_uri))
if artist is not None:
return SearchResult(artists=[artist])
elif type == uri.ALBUM:
album = self.lookup_album(uri.get_album_id(lookup_uri))
if album is not None:
return SearchResult(albums=[album])
elif type == uri.SONG:
song = self.lookup_song(uri.get_song_id(lookup_uri))
if song is not None:
return SearchResult(tracks=[song])
return None
def search_by_artist_album_and_track(self, artist_name, album_name, track_name):
tracks = self.search_by_artist_and_album(artist_name, album_name) tracks = self.search_by_artist_and_album(artist_name, album_name)
track = next(item for item in tracks.tracks if track_name in item.name) track = next(item for item in tracks.tracks if track_name in item.name)
return SearchResult(tracks=[track]) return SearchResult(tracks=[track])
def search_by_artist_and_album(self, artist_name, album_name): def search_by_artist_and_album(self, artist_name, album_name):
artists = self.subsonic_api.get_raw_artists() artists = self.subsonic_api.find_raw(artist_name).get("artist")
artist = next(item for item in artists if artist_name in item.get('name')) if artists is None:
albums = self.subsonic_api.get_raw_albums(artist.get('id')) return None
album = next(item for item in albums if album_name in item.get('title')) tracks = []
return SearchResult(tracks=self.subsonic_api.get_songs_as_tracks(album.get('id'))) for artist in artists:
for album in self.subsonic_api.get_raw_albums(artist.get("id")):
if album_name in album.get("name"):
tracks.extend(
self.subsonic_api.get_songs_as_tracks(album.get("id"))
)
return SearchResult(tracks=tracks)
def search_by_artist(self, artist_name, exact):
result = self.subsonic_api.find_raw(artist_name)
if result is None:
return None
tracks = []
for artist in result.get("artist"):
if exact:
if not artist.get("name") == artist_name:
continue
tracks.extend(
self.subsonic_api.get_artist_as_songs_as_tracks_iter(
artist.get("id")
)
)
return SearchResult(uri=uri.get_search_uri(artist_name), tracks=tracks)
def get_distinct(self, field, query): def get_distinct(self, field, query):
search_result = self.search(query) search_result = self.search(query)
if not search_result: if not search_result:
return [] return []
if field == 'track' or field == 'title': if field == "track" or field == "title":
return [track.name for track in (search_result.tracks or [])] return [track.name for track in (search_result.tracks or [])]
if field == 'album': if field == "album":
return [album.name for album in (search_result.albums or [])] return [album.name for album in (search_result.albums or [])]
if field == 'artist': if field == "artist":
if not search_result.artists: if not search_result.artists:
return [artist.name for artist in self.browse_artists()] return [artist.name for artist in self.browse_artists()]
return [artist.name for artist in search_result.artists] return [artist.name for artist in search_result.artists]
def search(self, query=None, uris=None, exact=False): def search(self, query=None, uris=None, exact=False):
if 'artist' in query and 'album' in query and 'track_name' in query: if "artist" in query and "album" in query and "track_name" in query:
return self.search_by_artist_album_and_track(query.get('artist')[0], query.get('album')[0], query.get('track_name')[0]) return self.search_by_artist_album_and_track(
if 'artist' in query and 'album' in query: query.get("artist")[0],
return self.search_by_artist_and_album(query.get('artist')[0], query.get('album')[0]) query.get("album")[0],
if 'artist' in query: query.get("track_name")[0],
return self.subsonic_api.find_as_search_result(query.get('artist')[0]) )
if 'any' in query: if "artist" in query and "album" in query:
return self.subsonic_api.find_as_search_result(query.get('any')[0]) return self.search_by_artist_and_album(
query.get("artist")[0], query.get("album")[0]
)
if "artist" in query:
return self.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,12 +1,14 @@
import logging
from mopidy import backend from mopidy import backend
from mopidy_subidy import uri from mopidy_subidy import uri
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SubidyPlaybackProvider(backend.PlaybackProvider): class SubidyPlaybackProvider(backend.PlaybackProvider):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SubidyPlaybackProvider, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api self.subsonic_api = self.backend.subsonic_api
def translate_uri(self, translate_uri): def translate_uri(self, translate_uri):
@ -14,3 +16,6 @@ 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,35 +1,57 @@
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(SubidyPlaylistsProvider, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.subsonic_api = self.backend.subsonic_api self.subsonic_api = self.backend.subsonic_api
self.playlists = [] self.playlists = []
self.refresh() self.refresh()
def as_list(self): def as_list(self):
return self.playlists return self.subsonic_api.get_playlists_as_refs()
def create(self, name): def create(self, name):
pass result = self.subsonic_api.create_playlist_raw(name)
if result is None:
return None
playlist = result.get("playlist")
if playlist is None:
for pl in self.subsonic_api.get_playlists_as_playlists():
if pl.name == name:
playlist = pl
return playlist
else:
return self.subsonic_api.raw_playlist_to_playlist(playlist)
def delete(self, uri): def delete(self, playlist_uri):
pass playlist_id = uri.get_playlist_id(playlist_uri)
self.subsonic_api.delete_playlist_raw(playlist_id)
def get_items(self, items_uri): def get_items(self, items_uri):
#logger.info('ITEMS %s: %s' % (lookup_uri, self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri)))) return self.subsonic_api.get_playlist_as_songs_as_refs(
return self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri)) uri.get_playlist_id(items_uri)
)
def lookup(self, lookup_uri): def lookup(self, lookup_uri):
#logger.info('LOOKUP PLAYLIST %s: %s' % (lookup_uri, self.subsonic_api.get_playlist_as_playlist(uri.get_playlist_id(lookup_uri)))) return self.subsonic_api.get_playlist_as_playlist(
return self.subsonic_api.get_playlist_as_playlist(uri.get_playlist_id(lookup_uri)) uri.get_playlist_id(lookup_uri)
)
def refresh(self): def refresh(self):
self.playlists = self.subsonic_api.get_playlists_as_refs() pass
def save(self, playlist): def save(self, playlist):
pass playlist_id = uri.get_playlist_id(playlist.uri)
track_ids = []
for trk in playlist.tracks:
track_ids.append(uri.get_song_id(trk.uri))
result = self.subsonic_api.save_playlist_raw(playlist_id, track_ids)
if result is None:
return None
return playlist

View file

@ -1,25 +1,28 @@
from urlparse import urlparse
import libsonic
import logging import logging
import itertools
from mopidy.models import Track, Album, Artist, Playlist, Ref, SearchResult
import re import re
from urllib.parse import urlencode, urlparse
import libsonic
from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track
from mopidy_subidy import uri from mopidy_subidy import uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RESPONSE_OK = u'ok' RESPONSE_OK = "ok"
UNKNOWN_SONG = u'Unknown Song' UNKNOWN_SONG = "Unknown Song"
UNKNOWN_ALBUM = u'Unknown Album' UNKNOWN_ALBUM = "Unknown Album"
UNKNOWN_ARTIST = u'Unknown Artist' UNKNOWN_ARTIST = "Unknown Artist"
MAX_SEARCH_RESULTS = 100 MAX_SEARCH_RESULTS = 100
MAX_LIST_RESULTS = 500 MAX_LIST_RESULTS = 500
ref_sort_key = lambda ref: ref.name
def ref_sort_key(ref):
return ref.name
def string_nums_nocase_sort_key(s): def string_nums_nocase_sort_key(s):
segments = [] segments = []
for substr in re.split(r'(\d+)', s): for substr in re.split(r"(\d+)", s):
if substr.isdigit(): if substr.isdigit():
seg = int(substr) seg = int(substr)
else: else:
@ -27,248 +30,517 @@ def string_nums_nocase_sort_key(s):
segments.append(seg) segments.append(seg)
return segments return segments
def diritem_sort_key(item): def diritem_sort_key(item):
isdir = item['isDir'] isdir = item["isDir"]
if isdir: if isdir:
key = string_nums_nocase_sort_key(item['title']) key = string_nums_nocase_sort_key(item["title"])
else: else:
key = int(item.get('track', 1)) key = int(item.get("track", 1))
return (isdir, key) return (isdir, key)
class SubsonicApi():
def __init__(self, url, username, password, legacy_auth): class SubsonicApi:
def __init__(
self, url, username, password, app_name, legacy_auth, api_version
):
parsed = urlparse(url) parsed = urlparse(url)
self.port = parsed.port if parsed.port else \ self.port = (
443 if parsed.scheme == 'https' else 80 parsed.port
base_url = parsed.scheme + '://' + parsed.hostname if parsed.port
else 443
if parsed.scheme == "https"
else 80
)
base_url = parsed.scheme + "://" + parsed.hostname
self.connection = libsonic.Connection( self.connection = libsonic.Connection(
base_url, base_url,
username, username,
password, password,
self.port, self.port,
parsed.path + '/rest', parsed.path + "/rest",
legacyAuth=legacy_auth) appName=app_name,
self.url = url + '/rest' legacyAuth=legacy_auth,
apiVersion=api_version,
)
self.url = url + "/rest"
self.username = username self.username = username
self.password = password self.password = password
logger.info('Connecting to subsonic server on url %s as user %s' % (url, username)) logger.info(
f"Connecting to subsonic server on url {url} as user {username}, "
f"API version {api_version}"
)
try: try:
self.connection.ping() self.connection.ping()
except Exception as e: except Exception as e:
logger.error('Unabled to reach subsonic server: %s' % e) logger.error("Unable to reach subsonic server: %s" % e)
exit() exit()
def get_subsonic_uri(self, view_name, params, censor=False):
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):
template = '%s/stream.view?id=%s&u=%s&p=%s&c=mopidy&v=1.14' return self.get_subsonic_uri("stream", dict(id=song_id))
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):
template = '%s/stream.view?id=%s&u=******&p=******&c=mopidy&v=1.14' return self.get_subsonic_uri("stream", dict(id=song_id), True)
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: try:
response = self.connection.search2( response = self.connection.search3(
query.encode('utf-8'), query.encode("utf-8"),
MAX_SEARCH_RESULTS if not exclude_artists else 0, 0, MAX_SEARCH_RESULTS if not exclude_artists else 0,
MAX_SEARCH_RESULTS if not exclude_albums else 0, 0, 0,
MAX_SEARCH_RESULTS if not exclude_songs else 0, 0) MAX_SEARCH_RESULTS if not exclude_albums else 0,
except Exception as e: 0,
logger.warning('Connecting to subsonic failed when searching.') MAX_SEARCH_RESULTS if not exclude_songs else 0,
0,
)
except Exception:
logger.warning("Connecting to subsonic failed when searching.")
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return response.get('searchResult2') return response.get("searchResult3")
def find_as_search_result(self, query, exclude_artists=False, exclude_albums=False, exclude_songs=False): def find_as_search_result(
self,
query,
exclude_artists=False,
exclude_albums=False,
exclude_songs=False,
):
result = self.find_raw(query) result = self.find_raw(query)
if result is None: if result is None:
return None return None
return SearchResult( return SearchResult(
uri=uri.get_search_uri(query), uri=uri.get_search_uri(query),
artists=[self.raw_artist_to_artist(artist) for artist in result.get('artist') or []], artists=[
albums=[self.raw_album_to_album(album) for album in result.get('album') or []], self.raw_artist_to_artist(artist)
tracks=[self.raw_song_to_track(song) for song in result.get('song') or []]) for artist in result.get("artist") or []
],
albums=[
self.raw_album_to_album(album)
for album in result.get("album") or []
],
tracks=[
self.raw_song_to_track(song)
for song in result.get("song") or []
],
)
def create_playlist_raw(self, name):
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 as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of artists.') logger.warning(
"Connecting to subsonic failed when loading list of artists."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
letters = response.get('artists').get('index') letters = response.get("artists").get("index")
if letters is not None: if letters is not None:
artists = [artist for letter in letters for artist in letter.get('artist') or []] artists = [
artist
for letter in letters
for artist in letter.get("artist") or []
]
return artists return artists
logger.warning('Subsonic does not seem to have any artists in it\'s library.') logger.warning(
"Subsonic does not seem to have any artists in it's library."
)
return [] return []
def get_raw_rootdirs(self): def get_raw_rootdirs(self):
try: try:
response = self.connection.getIndexes() response = self.connection.getIndexes()
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of rootdirs.') logger.warning(
"Connecting to subsonic failed when loading list of rootdirs."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
letters = response.get('indexes').get('index') letters = response.get("indexes").get("index")
if letters is not None: if letters is not None:
artists = [artist for letter in letters for artist in letter.get('artist') or []] artists = [
artist
for letter in letters
for artist in letter.get("artist") or []
]
return artists return artists
logger.warning('Subsonic does not seem to have any rootdirs in its library.') logger.warning(
"Subsonic does not seem to have any rootdirs in its library."
)
return [] return []
def get_song_by_id(self, song_id): def get_song_by_id(self, song_id):
try: try:
response = self.connection.getSong(song_id) response = self.connection.getSong(song_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading song by id.') logger.warning(
"Connecting to subsonic failed when loading song by id."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return self.raw_song_to_track(response.get('song')) if response.get('song') is not None else None return (
self.raw_song_to_track(response.get("song"))
if response.get("song") is not None
else None
)
def get_album_by_id(self, album_id): def get_album_by_id(self, album_id):
try: try:
response = self.connection.getAlbum(album_id) response = self.connection.getAlbum(album_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading album by id.') logger.warning(
"Connecting to subsonic failed when loading album by id."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return self.raw_album_to_album(response.get('album')) if response.get('album') is not None else None return (
self.raw_album_to_album(response.get("album"))
if response.get("album") is not None
else None
)
def get_artist_by_id(self, artist_id): def get_artist_by_id(self, artist_id):
try: try:
response = self.connection.getArtist(artist_id) response = self.connection.getArtist(artist_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading artist by id.') logger.warning(
"Connecting to subsonic failed when loading artist by id."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return self.raw_artist_to_artist(response.get('artist')) if response.get('artist') is not None else None return (
self.raw_artist_to_artist(response.get("artist"))
if response.get("artist") is not None
else None
)
def get_raw_playlists(self): def get_raw_playlists(self):
try: try:
response = self.connection.getPlaylists() response = self.connection.getPlaylists()
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of playlists.') logger.warning(
"Connecting to subsonic failed when loading list of playlists."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
playlists = response.get('playlists').get('playlist') playlists = response.get("playlists").get("playlist")
if playlists is None: if playlists is None:
logger.warning('Subsonic does not seem to have any playlists in it\'s library.') logger.warning(
"Subsonic does not seem to have any playlists in it's library."
)
return [] return []
return playlists return playlists
def get_raw_playlist(self, playlist_id): def get_raw_playlist(self, playlist_id):
try: try:
response = self.connection.getPlaylist(playlist_id) response = self.connection.getPlaylist(playlist_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading playlist.') logger.warning(
"Connecting to subsonic failed when loading playlist."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
return response.get('playlist') return response.get("playlist")
def get_raw_dir(self, parent_id): def get_raw_dir(self, parent_id):
try: try:
response = self.connection.getMusicDirectory(parent_id) response = self.connection.getMusicDirectory(parent_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when listing content of music directory.') logger.warning(
"Connecting to subsonic failed when listing content of music directory."
)
return None return None
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return None return None
directory = response.get('directory') directory = response.get("directory")
if directory is not None: if directory is not None:
diritems = directory.get('child') diritems = directory.get("child")
return sorted(diritems, key=diritem_sort_key) return sorted(diritems, key=diritem_sort_key)
return None return None
def get_raw_albums(self, artist_id): def get_raw_albums(self, artist_id):
try: try:
response = self.connection.getArtist(artist_id) response = self.connection.getArtist(artist_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of albums.') logger.warning(
"Connecting to subsonic failed when loading list of albums."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
albums = response.get('artist').get('album') albums = response.get("artist").get("album")
if albums is not None: if albums is not None:
return sorted(albums, key=lambda album: string_nums_nocase_sort_key(album['name'])) return sorted(
albums,
key=lambda album: string_nums_nocase_sort_key(album["name"]),
)
return [] return []
def get_raw_songs(self, album_id): def get_raw_songs(self, album_id):
try: try:
response = self.connection.getAlbum(album_id) response = self.connection.getAlbum(album_id)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading list of songs in album.') logger.warning(
"Connecting to subsonic failed when loading list of songs in album."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
songs = response.get('album').get('song') songs = response.get("album").get("song")
if songs is not None: if songs is not None:
return songs return songs
return [] return []
def get_raw_album_list(self, ltype, size=MAX_LIST_RESULTS): def get_raw_random_song(self, size=MAX_LIST_RESULTS):
try: try:
response = self.connection.getAlbumList2(ltype=ltype, size=size) response = self.connection.getRandomSongs(size)
except Exception as e: except Exception:
logger.warning('Connecting to subsonic failed when loading album list.') logger.warning(
"Connecting to subsonic failed when loading ramdom song list."
)
return [] return []
if response.get('status') != RESPONSE_OK: if response.get("status") != RESPONSE_OK:
logger.warning('Got non-okay status code from subsonic: %s' % response.get('status')) logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return [] return []
albums = response.get('albumList2').get('album') songs = response.get("randomSongs").get("song")
if songs is not None:
return songs
return []
def get_more_albums(self, ltype, size=MAX_LIST_RESULTS, offset=0):
try:
response = self.connection.getAlbumList2(
ltype=ltype, size=size, offset=offset
)
except Exception:
logger.warning(
"Connecting to subsonic failed when loading album list."
)
return []
if response.get("status") != RESPONSE_OK:
logger.warning(
"Got non-okay status code from subsonic: %s"
% response.get("status")
)
return []
albums = response.get("albumList2").get("album")
if albums is not None: 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 = (self.get_raw_album_list('alphabeticalByName') if artist_id is None else self.get_raw_albums(artist_id)) albums = (
self.get_raw_album_list("alphabeticalByName")
if artist_id is None
else self.get_raw_albums(artist_id)
)
return [self.raw_album_to_ref(album) for album in albums] return [self.raw_album_to_ref(album) for album in albums]
def get_albums_as_albums(self, artist_id): def get_albums_as_albums(self, artist_id):
return [self.raw_album_to_album(album) for album in self.get_raw_albums(artist_id)] return [
self.raw_album_to_album(album)
for album in self.get_raw_albums(artist_id)
]
def get_songs_as_refs(self, album_id): def get_songs_as_refs(self, album_id):
return [self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)] return [
self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)
]
def get_songs_as_tracks(self, album_id): def get_songs_as_tracks(self, album_id):
return [self.raw_song_to_track(song) for song in self.get_raw_songs(album_id)] return [
self.raw_song_to_track(song)
for song in self.get_raw_songs(album_id)
]
def get_artists_as_refs(self): def get_artists_as_refs(self):
return [self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()] return [
self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()
]
def get_rootdirs_as_refs(self): def get_rootdirs_as_refs(self):
return [self.raw_directory_to_ref(rootdir) for rootdir in self.get_raw_rootdirs()] return [
self.raw_directory_to_ref(rootdir)
for rootdir in self.get_raw_rootdirs()
]
def get_diritems_as_refs(self, directory_id): def get_diritems_as_refs(self, directory_id):
return [(self.raw_directory_to_ref(diritem) if diritem.get('isDir') else self.raw_song_to_ref(diritem)) for diritem in self.get_raw_dir(directory_id)] return [
(
self.raw_directory_to_ref(diritem)
if diritem.get("isDir")
else self.raw_song_to_ref(diritem)
)
for diritem in self.get_raw_dir(directory_id)
]
def get_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 [self.raw_artist_to_artist(artist) for artist in self.get_raw_artists()] return [
self.raw_artist_to_artist(artist)
for artist in self.get_raw_artists()
]
def get_playlists_as_refs(self): def get_playlists_as_refs(self):
return [self.raw_playlist_to_ref(playlist) for playlist in self.get_raw_playlists()] return [
self.raw_playlist_to_ref(playlist)
for playlist in self.get_raw_playlists()
]
def get_playlists_as_playlists(self): def get_playlists_as_playlists(self):
return [self.raw_playlist_to_playlist(playlist) for playlist in self.get_raw_playlists()] return [
self.raw_playlist_to_playlist(playlist)
for playlist in self.get_raw_playlists()
]
def get_playlist_as_playlist(self, playlist_id): def get_playlist_as_playlist(self, playlist_id):
return self.raw_playlist_to_playlist(self.get_raw_playlist(playlist_id)) return self.raw_playlist_to_playlist(self.get_raw_playlist(playlist_id))
@ -277,85 +549,130 @@ 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 if song.get('duration') else None, length=int(song.get("duration")) * 1000
disc_no=int(song.get('discNumber')) if song.get('discNumber') else None, if song.get("duration")
artists=[Artist( else None,
name=song.get('artist'), disc_no=int(song.get("discNumber"))
uri=uri.get_artist_uri(song.get('artistId')))], if song.get("discNumber")
else None,
artists=[
Artist(
name=song.get("artist"),
uri=uri.get_artist_uri(song.get("artistId")),
)
],
album=Album( album=Album(
name=song.get('album'), name=song.get("album"),
uri=uri.get_album_uri(song.get('albumId')))) uri=uri.get_album_uri(song.get("albumId")),
),
)
def raw_album_to_ref(self, album): def raw_album_to_ref(self, album):
if album is None: if album is None:
return None return None
return Ref.album( return Ref.album(
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, name=album.get("title") or album.get("name") or UNKNOWN_ALBUM,
uri=uri.get_album_uri(album.get('id'))) uri=uri.get_album_uri(album.get("id")),
)
def raw_album_to_album(self, album): def raw_album_to_album(self, album):
if album is None: if album is None:
return None return None
return Album( return Album(
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM, name=album.get("title") or album.get("name") or UNKNOWN_ALBUM,
uri=uri.get_album_uri(album.get('id')), num_tracks=album.get("songCount"),
artists=[Artist( uri=uri.get_album_uri(album.get("id")),
name=album.get('artist'), artists=[
uri=uri.get_artist_uri(album.get('artistId')))]) Artist(
name=album.get("artist"),
uri=uri.get_artist_uri(album.get("artistId")),
)
],
)
def raw_directory_to_ref(self, directory): def raw_directory_to_ref(self, directory):
if directory is None: if directory is None:
return None return None
return Ref.directory( return Ref.directory(
name=directory.get('title') or directory.get('name'), name=directory.get("title") or directory.get("name"),
uri=uri.get_directory_uri(directory.get('id'))) uri=uri.get_directory_uri(directory.get("id")),
)
def raw_artist_to_ref(self, artist): def raw_artist_to_ref(self, artist):
if artist is None: if artist is None:
return None return None
return Ref.artist( return Ref.artist(
name=artist.get('name') or UNKNOWN_ARTIST, name=artist.get("name") or UNKNOWN_ARTIST,
uri=uri.get_artist_uri(artist.get('id'))) uri=uri.get_artist_uri(artist.get("id")),
)
def raw_artist_to_artist(self, artist): def raw_artist_to_artist(self, artist):
if artist is None: if artist is None:
return None return None
return Artist( return Artist(
name=artist.get('name') or UNKNOWN_ARTIST, name=artist.get("name") or UNKNOWN_ARTIST,
uri=uri.get_artist_uri(artist.get('id'))) uri=uri.get_artist_uri(artist.get("id")),
)
def raw_playlist_to_playlist(self, playlist): def raw_playlist_to_playlist(self, playlist):
if playlist is None: if playlist is None:
return None return None
entries = playlist.get('entry') entries = playlist.get("entry")
tracks = [self.raw_song_to_track(song) for song in entries] if entries is not None else None tracks = (
[self.raw_song_to_track(song) for song in entries]
if entries is not None
else None
)
return Playlist( return Playlist(
uri=uri.get_playlist_uri(playlist.get('id')), uri=uri.get_playlist_uri(playlist.get("id")),
name=playlist.get('name'), name=playlist.get("name"),
tracks=tracks) tracks=tracks,
)
def raw_playlist_to_ref(self, playlist): def raw_playlist_to_ref(self, playlist):
if playlist is None: if playlist is None:
return None return None
return Ref.playlist( return Ref.playlist(
uri=uri.get_playlist_uri(playlist.get('id')), uri=uri.get_playlist_uri(playlist.get("id")),
name=playlist.get('name')) name=playlist.get("name"),
)

View file

@ -1,87 +1,110 @@
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 is_type_result_valid(result) and result.group(1) == PREFIX and result.group(2) == type return (
is_type_result_valid(result)
and result.group(1) == PREFIX
and result.group(2) == type
)
def is_uri(uri): def is_uri(uri):
return regex.match(uri) is not None return regex.match(uri) is not None
def get_song_id(uri): def get_song_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, SONG): if not is_id_result_valid(result, SONG):
return None return None
return result.group(3) return result.group(3)
def get_artist_id(uri): def get_artist_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, ARTIST): if not is_id_result_valid(result, ARTIST):
return None return None
return result.group(3) return result.group(3)
def get_playlist_id(uri): def get_playlist_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, PLAYLIST): if not is_id_result_valid(result, PLAYLIST):
return None return None
return result.group(3) return result.group(3)
def get_album_id(uri): def get_album_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, ALBUM): if not is_id_result_valid(result, ALBUM):
return None return None
return result.group(3) return result.group(3)
def get_directory_id(uri): def get_directory_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, DIRECTORY): if not is_id_result_valid(result, DIRECTORY):
return None return None
return result.group(3) return result.group(3)
def get_vdir_id(uri): def get_vdir_id(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_id_result_valid(result, VDIR): if not is_id_result_valid(result, VDIR):
return None return None
return result.group(3) return result.group(3)
def get_type(uri): def get_type(uri):
result = regex.match(uri) result = regex.match(uri)
if not is_type_result_valid(result): if not is_type_result_valid(result):
return None return None
return result.group(2) return result.group(2)
def get_type_uri(type, id): def get_type_uri(type, id):
return u'%s:%s:%s' % (PREFIX, type, id) return f"{PREFIX}:{type}:{id}"
def get_artist_uri(id): def get_artist_uri(id):
return get_type_uri(ARTIST, id) return get_type_uri(ARTIST, id)
def get_album_uri(id): def get_album_uri(id):
return get_type_uri(ALBUM, id) return get_type_uri(ALBUM, id)
def get_song_uri(id): def get_song_uri(id):
return get_type_uri(SONG, id) return get_type_uri(SONG, id)
def get_directory_uri(id): def get_directory_uri(id):
return get_type_uri(DIRECTORY, id) return get_type_uri(DIRECTORY, id)
def get_vdir_uri(id): def get_vdir_uri(id):
return get_type_uri(VDIR, id) return get_type_uri(VDIR, id)
def get_playlist_uri(id): def get_playlist_uri(id):
return get_type_uri(PLAYLIST, id) return get_type_uri(PLAYLIST, id)
def get_search_uri(query): def get_search_uri(query):
return get_type_uri(SEARCH, query) return get_type_uri(SEARCH, query)

17
pyproject.toml Normal file
View file

@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools >= 30.3.0", "wheel"]
[tool.black]
target-version = ["py37", "py38"]
line-length = 80
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
line_length = 88
known_tests = "tests"
sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER"

86
setup.cfg Normal file
View file

@ -0,0 +1,86 @@
[metadata]
name = Mopidy-Subidy
version = 1.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,43 +1,3 @@
from __future__ import unicode_literals from setuptools import setup
import re setup()
from setuptools import setup, find_packages
def get_version(filename):
content = open(filename).read()
metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content))
return metadata['version']
setup(
name='Mopidy-Subidy',
version=get_version('mopidy_subidy/__init__.py'),
url='http://github.com/prior99/mopidy-subidy/',
license='BSD-3-Clause',
author='prior99',
author_email='fgnodtke@cronosx.de',
description='Improved Subsonic extension for Mopidy',
long_description=open('README.md').read(),
packages=find_packages(exclude=['tests', 'tests.*']),
zip_safe=False,
include_package_data=True,
install_requires=[
'setuptools',
'Mopidy >= 2.0',
'py-sonic >= 0.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'
]
)

0
tests/__init__.py Normal file
View file

23
tests/test_extension.py Normal file
View file

@ -0,0 +1,23 @@
from mopidy_subidy import SubidyExtension
def test_get_default_config():
ext = SubidyExtension()
config = ext.get_default_config()
assert "[subidy]" in config
assert "enabled = true" in config
def test_get_config_schema():
ext = SubidyExtension()
schema = ext.get_config_schema()
# TODO Test the content of your config schema
assert "url" in schema
# assert "password" in schema
# TODO Write more tests

23
tox.ini Normal file
View file

@ -0,0 +1,23 @@
[tox]
envlist = py37, py38, black, check-manifest, flake8
[testenv]
sitepackages = true
deps = .[test]
commands =
python -m pytest \
--basetemp={envtmpdir} \
--cov=mopidy_subidy --cov-report=term-missing \
{posargs}
[testenv:black]
deps = .[lint]
commands = python -m black --check .
[testenv:check-manifest]
deps = .[lint]
commands = python -m check_manifest
[testenv:flake8]
deps = .[lint]
commands = python -m flake8 --show-source --statistics