1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-09-19 10:51:04 +00:00

Implement musicFolderId paramenter on various endpoints

Bumped API version to 1.12.0 along the way.
`getArtits` also got it event if it seems it has been added with version 1.14.0,
but I'm a bit concerned as to how clients will behave on authentication if the server
advertise itself as 1.13.0+

Closes #235
Ref #74
This commit is contained in:
Alban Féron 2022-09-10 15:50:10 +02:00
parent bc373e595f
commit e52a7043b0
No known key found for this signature in database
GPG Key ID: 8CE0313646D16165
11 changed files with 301 additions and 107 deletions

View File

@ -16,7 +16,7 @@ Current supported features are:
* [Last.fm][lastfm] scrobbling
* Jukebox mode
Supysonic currently targets the version 1.10.2 of the Subsonic API. For more
Supysonic currently targets the version 1.12.0 of the Subsonic API. For more
details, go check the [API implementation status][docs-api].
[subsonic]: http://www.subsonic.org/

View File

@ -5,7 +5,7 @@ This page lists all the API methods and their parameters up to the version
1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features
Supysonic support, plan on supporting, or won't.
At the moment, the current target API version is 1.10.2.
At the moment, the current target API version is 1.12.0.
The following information was gathered by *diff*-ing various snapshots of the
`Subsonic API page`__.
@ -227,7 +227,7 @@ Browsing
================= ====== =
Parameter Vers.
================= ====== =
``musicFolderId`` 1.14.0 📅
``musicFolderId`` 1.14.0 ✔️
================= ====== =
.. _getArtist:
@ -418,7 +418,7 @@ Album/song lists
``fromYear`` ✔️
``toYear`` ✔️
``genre`` ✔️
``musicFolderId`` 1.12.0 📅
``musicFolderId`` 1.12.0 ✔️
================= ====== =
.. versionadded:: 1.10.1
@ -441,7 +441,7 @@ Album/song lists
``fromYear`` ✔️
``toYear`` ✔️
``genre`` ✔️
``musicFolderId`` 1.12.0 📅
``musicFolderId`` 1.12.0 ✔️
================= ====== =
.. versionadded:: 1.10.1
@ -479,7 +479,7 @@ Album/song lists
``genre`` 1.9.0 ✔️
``count`` 1.9.0 ✔️
``offset`` 1.9.0 ✔️
``musicFolderId`` 1.12.0 📅
``musicFolderId`` 1.12.0 ✔️
================= ====== =
.. _getNowPlaying:
@ -500,7 +500,7 @@ Album/song lists
================= ====== =
Parameter Vers.
================= ====== =
``musicFolderId`` 1.12.0 📅
``musicFolderId`` 1.12.0 ✔️
================= ====== =
.. _getStarred2:
@ -514,7 +514,7 @@ Album/song lists
================= ====== =
Parameter Vers.
================= ====== =
``musicFolderId`` 1.12.0 📅
``musicFolderId`` 1.12.0 ✔️
================= ====== =
Searching
@ -558,7 +558,7 @@ Searching
``albumOffset`` ✔️
``songCount`` ✔️
``songOffset`` ✔️
``musicFolderId`` 1.12.0 📅
``musicFolderId`` 1.12.0 ✔️
================= ====== =
.. _search3:
@ -579,7 +579,7 @@ Searching
``albumOffset`` ✔️
``songCount`` ✔️
``songOffset`` ✔️
``musicFolderId`` 1.12.0 📅
``musicFolderId`` 1.12.0 ✔️
================= ====== =
Playlists

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2013-2020 Alban 'spl0k' Féron
# Copyright (C) 2013-2022 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
@ -17,7 +17,7 @@ from pony.orm import commit
from ..db import ClientPrefs, Folder
from ..managers.user import UserManager
from .exceptions import GenericError, Unauthorized
from .exceptions import GenericError, Unauthorized, NotFound
from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter
api = Blueprint("api", __name__)
@ -119,6 +119,22 @@ def get_entity_id(cls, eid):
raise GenericError("Invalid ID")
def get_root_folder(id):
if id is None:
return None
try:
fid = int(id)
except ValueError:
raise ValueError("Invalid folder ID")
folder = Folder.get(id=fid, root=True)
if folder is None:
raise NotFound("Folder")
return folder
from .errors import *
from .system import *

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2013-2020 Alban 'spl0k' Féron
# Copyright (C) 2013-2022 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
@ -21,8 +21,8 @@ from ..db import (
)
from ..db import now
from . import api_routing
from .exceptions import GenericError, NotFound
from . import api_routing, get_root_folder
from .exceptions import GenericError
@api_routing("/getRandomSongs")
@ -35,12 +35,7 @@ def rand_songs():
size = int(size) if size else 10
fromYear = int(fromYear) if fromYear else None
toYear = int(toYear) if toYear else None
fid = None
if musicFolderId:
try:
fid = int(musicFolderId)
except ValueError:
raise ValueError("Invalid folder ID")
root = get_root_folder(musicFolderId)
query = Track.select()
if fromYear:
@ -49,11 +44,8 @@ def rand_songs():
query = query.filter(lambda t: t.year <= toYear)
if genre:
query = query.filter(lambda t: t.genre == genre)
if fid:
if not Folder.exists(id=fid, root=True):
raise NotFound("Folder")
query = query.filter(lambda t: t.root_folder.id == fid)
if root:
query = query.filter(lambda t: t.root_folder == root)
return request.formatter(
"randomSongs",
@ -70,11 +62,15 @@ def rand_songs():
def album_list():
ltype = request.values["type"]
size, offset = map(request.values.get, ("size", "offset"))
size, offset, mfid = map(request.values.get, ("size", "offset", "musicFolderId"))
size = int(size) if size else 10
offset = int(offset) if offset else 0
root = get_root_folder(mfid)
query = select(t.folder for t in Track)
if root is not None:
query = select(t.folder for t in Track if t.root_folder == root)
if ltype == "random":
return request.formatter(
"albumList",
@ -94,13 +90,18 @@ def album_list():
elif ltype == "recent":
query = select(
t.folder for t in Track if max(t.folder.tracks.last_play) is not None
).sort_by(lambda f: desc(max(f.tracks.last_play)))
)
if root is not None:
query = query.where(lambda t: t.root_folder == root)
query = query.sort_by(lambda f: desc(max(f.tracks.last_play)))
elif ltype == "starred":
query = select(
s.starred
for s in StarredFolder
if s.user.id == request.user.id and count(s.starred.tracks) > 0
)
if root is not None:
query = query.filter(lambda f: f.path.startswith(root.path))
elif ltype == "alphabeticalByName":
query = query.sort_by(Folder.name).distinct()
elif ltype == "alphabeticalByArtist":
@ -135,11 +136,15 @@ def album_list():
def album_list_id3():
ltype = request.values["type"]
size, offset = map(request.values.get, ("size", "offset"))
size, offset, mfid = map(request.values.get, ("size", "offset", "musicFolderId"))
size = int(size) if size else 10
offset = int(offset) if offset else 0
root = get_root_folder(mfid)
query = Album.select()
if root is not None:
query = query.where(lambda a: root in a.tracks.root_folder)
if ltype == "random":
return request.formatter(
"albumList2",
@ -150,11 +155,13 @@ def album_list_id3():
elif ltype == "frequent":
query = query.order_by(lambda a: desc(avg(a.tracks.play_count)))
elif ltype == "recent":
query = Album.select(lambda a: max(a.tracks.last_play) is not None).order_by(
query = query.where(lambda a: max(a.tracks.last_play) is not None).order_by(
lambda a: desc(max(a.tracks.last_play))
)
elif ltype == "starred":
query = select(s.starred for s in StarredAlbum if s.user.id == request.user.id)
if root is not None:
query = query.filter(lambda a: root in a.tracks.root_folder)
elif ltype == "alphabeticalByName":
query = query.order_by(Album.name)
elif ltype == "alphabeticalByArtist":
@ -191,14 +198,22 @@ def album_list_id3():
def songs_by_genre():
genre = request.values["genre"]
count, offset = map(request.values.get, ("count", "offset"))
count, offset, mfid = map(request.values.get, ("count", "offset", "musicFolderId"))
count = int(count) if count else 10
offset = int(offset) if offset else 0
root = get_root_folder(mfid)
query = select(t for t in Track if t.genre == genre).limit(count, offset)
query = select(t for t in Track if t.genre == genre)
if root is not None:
query = query.where(lambda t: t.root_folder == root)
return request.formatter(
"songsByGenre",
{"song": [t.as_subsonic_child(request.user, request.client) for t in query]},
{
"song": [
t.as_subsonic_child(request.user, request.client)
for t in query.limit(count, offset)
]
},
)
@ -227,51 +242,49 @@ def now_playing():
@api_routing("/getStarred")
def get_starred():
mfid = request.values.get("musicFolderId")
root = get_root_folder(mfid)
folders = select(s.starred for s in StarredFolder if s.user.id == request.user.id)
if root is not None:
folders = folders.filter(lambda f: f.path.startswith(root.path))
arq = folders.filter(lambda f: count(f.tracks) == 0)
alq = folders.filter(lambda f: count(f.tracks) > 0)
trq = select(s.starred for s in StarredTrack if s.user.id == request.user.id)
if root is not None:
trq = trq.filter(lambda t: t.root_folder == root)
return request.formatter(
"starred",
{
"artist": [
sf.as_subsonic_artist(request.user)
for sf in folders.filter(lambda f: count(f.tracks) == 0)
],
"album": [
sf.as_subsonic_child(request.user)
for sf in folders.filter(lambda f: count(f.tracks) > 0)
],
"song": [
st.as_subsonic_child(request.user, request.client)
for st in select(
s.starred for s in StarredTrack if s.user.id == request.user.id
)
],
"artist": [sf.as_subsonic_artist(request.user) for sf in arq],
"album": [sf.as_subsonic_child(request.user) for sf in alq],
"song": [st.as_subsonic_child(request.user, request.client) for st in trq],
},
)
@api_routing("/getStarred2")
def get_starred_id3():
mfid = request.values.get("musicFolderId")
root = get_root_folder(mfid)
arq = select(s.starred for s in StarredArtist if s.user.id == request.user.id)
alq = select(s.starred for s in StarredAlbum if s.user.id == request.user.id)
trq = select(s.starred for s in StarredTrack if s.user.id == request.user.id)
if root is not None:
arq = arq.filter(lambda a: root in a.tracks.root_folder)
alq = alq.filter(lambda a: root in a.tracks.root_folder)
trq = trq.filter(lambda t: t.root_folder == root)
return request.formatter(
"starred2",
{
"artist": [
sa.as_subsonic_artist(request.user)
for sa in select(
s.starred for s in StarredArtist if s.user.id == request.user.id
)
],
"album": [
sa.as_subsonic_album(request.user)
for sa in select(
s.starred for s in StarredAlbum if s.user.id == request.user.id
)
],
"song": [
st.as_subsonic_child(request.user, request.client)
for st in select(
s.starred for s in StarredTrack if s.user.id == request.user.id
)
],
"artist": [sa.as_subsonic_artist(request.user) for sa in arq],
"album": [sa.as_subsonic_album(request.user) for sa in alq],
"song": [st.as_subsonic_child(request.user, request.client) for st in trq],
},
)

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2013-2020 Alban 'spl0k' Féron
# Copyright (C) 2013-2022 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
@ -9,11 +9,11 @@ import re
import string
from flask import current_app, request
from pony.orm import ObjectNotFound, select, count
from pony.orm import select, count
from ..db import Folder, Artist, Album, Track
from . import get_entity, get_entity_id, api_routing
from . import get_entity, get_root_folder, api_routing
@api_routing("/getMusicFolders")
@ -80,12 +80,7 @@ def list_indexes():
if musicFolderId is None:
folders = Folder.select(lambda f: f.root)[:]
else:
mfid = get_entity_id(Folder, musicFolderId)
folder = Folder[mfid]
if not folder.root:
raise ObjectNotFound(Folder, mfid)
folders = [folder]
folders = [get_root_folder(musicFolderId)]
last_modif = max(f.last_scan for f in folders)
if ifModifiedSince is not None and last_modif < ifModifiedSince:
@ -153,8 +148,14 @@ def list_genres():
@api_routing("/getArtists")
def list_artists():
# According to the API page, there are no parameters?
indexes = build_indexes(Artist.select())
mfid = request.values.get("musicFolderId")
query = Artist.select()
if mfid is not None:
folder = get_root_folder(mfid)
query = Artist.select(lambda a: folder in a.tracks.root_folder)
indexes = build_indexes(query)
return request.formatter(
"artists",
{

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2013-2020 Alban 'spl0k' Féron
# Copyright (C) 2013-2022 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
@ -12,7 +12,7 @@ from pony.orm import select
from ..db import Folder, Track, Artist, Album
from . import api_routing
from . import api_routing, get_root_folder
from .exceptions import MissingParameter
@ -93,6 +93,7 @@ def new_search():
album_offset,
song_count,
song_offset,
mfid,
) = map(
request.values.get,
(
@ -102,6 +103,7 @@ def new_search():
"albumOffset",
"songCount",
"songOffset",
"musicFolderId",
),
)
@ -111,14 +113,20 @@ def new_search():
album_offset = int(album_offset) if album_offset else 0
song_count = int(song_count) if song_count else 20
song_offset = int(song_offset) if song_offset else 0
root = get_root_folder(mfid)
artists = select(
t.folder.parent for t in Track if query in t.folder.parent.name
).limit(artist_count, artist_offset)
albums = select(t.folder for t in Track if query in t.folder.name).limit(
album_count, album_offset
)
songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset)
artists = select(t.folder.parent for t in Track if query in t.folder.parent.name)
albums = select(t.folder for t in Track if query in t.folder.name)
songs = Track.select(lambda t: query in t.title)
if root is not None:
artists = artists.where(lambda t: t.root_folder == root)
albums = albums.where(lambda t: t.root_folder == root)
songs = songs.where(lambda t: t.root_folder == root)
artists = artists.limit(artist_count, artist_offset)
albums = albums.limit(album_count, album_offset)
songs = songs.limit(song_count, song_offset)
return request.formatter(
"searchResult2",
@ -145,6 +153,7 @@ def search_id3():
album_offset,
song_count,
song_offset,
mfid,
) = map(
request.values.get,
(
@ -154,6 +163,7 @@ def search_id3():
"albumOffset",
"songCount",
"songOffset",
"musicFolderId",
),
)
@ -163,12 +173,20 @@ def search_id3():
album_offset = int(album_offset) if album_offset else 0
song_count = int(song_count) if song_count else 20
song_offset = int(song_offset) if song_offset else 0
root = get_root_folder(mfid)
artists = Artist.select(lambda a: query in a.name).limit(
artist_count, artist_offset
)
albums = Album.select(lambda a: query in a.name).limit(album_count, album_offset)
songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset)
artists = Artist.select(lambda a: query in a.name)
albums = Album.select(lambda a: query in a.name)
songs = Track.select(lambda t: query in t.title)
if root is not None:
artists = artists.where(lambda a: root in a.tracks.root_folder)
albums = albums.where(lambda a: root in a.tracks.root_folder)
songs = songs.where(lambda t: t.root_folder == root)
artists = artists.limit(artist_count, artist_offset)
albums = albums.limit(album_count, album_offset)
songs = songs.limit(song_count, song_offset)
return request.formatter(
"searchResult3",

View File

@ -20,7 +20,7 @@ NSMAP = {"sub": NS}
class ApiTestBase(TestBase):
__with_api__ = True
def setUp(self, apiVersion="1.10.2"):
def setUp(self, apiVersion="1.12.0"):
super().setUp()
self.apiVersion = apiVersion
xsd = etree.parse(

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2017-2020 Alban 'spl0k' Féron
# Copyright (C) 2017-2022 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
@ -23,6 +23,7 @@ class AlbumSongsTestCase(ApiTestBase):
with db_session:
folder = Folder(name="Root", root=True, path="tests/assets")
empty = Folder(name="Root", root=True, path="/tmp")
artist = Artist(name="Artist")
album = Album(name="Album", artist=artist)
@ -70,6 +71,12 @@ class AlbumSongsTestCase(ApiTestBase):
error=0,
)
self._make_request("getAlbumList", {"type": "byGenre"}, error=10)
self._make_request(
"getAlbumList", {"type": "random", "musicFolderId": "id"}, error=0
)
self._make_request(
"getAlbumList", {"type": "random", "musicFolderId": 12}, error=70
)
types_and_count = [
("random", 1),
@ -120,8 +127,21 @@ class AlbumSongsTestCase(ApiTestBase):
)
self.assertEqual(len(child), 1)
_, child = self._make_request(
"getAlbumList",
{"musicFolderId": 1, "type": "alphabeticalByName"},
tag="albumList",
)
self.assertEqual(len(child), 1)
_, child = self._make_request(
"getAlbumList",
{"musicFolderId": 2, "type": "alphabeticalByName"},
tag="albumList",
)
self.assertEqual(len(child), 0)
with db_session:
Folder.get().delete()
Folder[1].delete()
rv, child = self._make_request(
"getAlbumList", {"type": "random"}, tag="albumList"
)
@ -143,6 +163,12 @@ class AlbumSongsTestCase(ApiTestBase):
error=0,
)
self._make_request("getAlbumList2", {"type": "byGenre"}, error=10)
self._make_request(
"getAlbumList2", {"type": "random", "musicFolderId": "id"}, error=0
)
self._make_request(
"getAlbumList2", {"type": "random", "musicFolderId": 12}, error=70
)
types = [
"random",
@ -192,6 +218,19 @@ class AlbumSongsTestCase(ApiTestBase):
)
self.assertEqual(len(child), 1)
_, child = self._make_request(
"getAlbumList2",
{"musicFolderId": 1, "type": "alphabeticalByName"},
tag="albumList2",
)
self.assertEqual(len(child), 1)
_, child = self._make_request(
"getAlbumList2",
{"musicFolderId": 2, "type": "alphabeticalByName"},
tag="albumList2",
)
self.assertEqual(len(child), 0)
with db_session:
Track.select().delete()
Album.get().delete()
@ -211,15 +250,13 @@ class AlbumSongsTestCase(ApiTestBase):
"getRandomSongs", tag="randomSongs", skip_post=True
)
with db_session:
fid = Folder.get().id
self._make_request(
"getRandomSongs",
{
"fromYear": -52,
"toYear": "1984",
"genre": "some cryptic subgenre youve never heard of",
"musicFolderId": fid,
"musicFolderId": 1,
},
tag="randomSongs",
)
@ -229,9 +266,11 @@ class AlbumSongsTestCase(ApiTestBase):
def test_get_starred(self):
self._make_request("getStarred", tag="starred")
self._make_request("getStarred", {"musicFolderId": 1}, tag="starred")
def test_get_starred2(self):
self._make_request("getStarred2", tag="starred2")
self._make_request("getStarred2", {"musicFolderId": 1}, tag="starred2")
if __name__ == "__main__":

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2017-2020 Alban 'spl0k' Féron
# Copyright (C) 2017-2022 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
@ -21,14 +21,14 @@ class BrowseTestCase(ApiTestBase):
super().setUp()
with db_session:
Folder(root=True, name="Empty root", path="/tmp")
root = Folder(root=True, name="Root folder", path="tests/assets")
self.empty_root = Folder(root=True, name="Empty root", path="/tmp")
self.root = Folder(root=True, name="Root folder", path="tests/assets")
for letter in "ABC":
folder = Folder(
name=letter + "rtist",
path="tests/assets/{}rtist".format(letter),
parent=root,
parent=self.root,
)
artist = Artist(name=letter + "rtist")
@ -56,7 +56,7 @@ class BrowseTestCase(ApiTestBase):
letter, lether, song
),
last_modification=0,
root_folder=root,
root_folder=self.root,
folder=afolder,
)
@ -132,13 +132,26 @@ class BrowseTestCase(ApiTestBase):
# same as getIndexes standard case
# dataset should be improved to have a different directory structure than /root/Artist/Album/Track
rv, child = self._make_request("getArtists", tag="artists")
_, child = self._make_request("getArtists", tag="artists")
self.assertEqual(len(child), 3)
for i, letter in enumerate(["A", "B", "C"]):
self.assertEqual(child[i].get("name"), letter)
self.assertEqual(len(child[i]), 1)
self.assertEqual(child[i][0].get("name"), letter + "rtist")
self._make_request("getArtists", {"musicFolderId": "id"}, error=0)
self._make_request("getArtists", {"musicFolderId": -3}, error=70)
_, child = self._make_request(
"getArtists", {"musicFolderId": str(self.empty_root.id)}, tag="artists"
)
self.assertEqual(len(child), 0)
_, child = self._make_request(
"getArtists", {"musicFolderId": str(self.root.id)}, tag="artists"
)
self.assertEqual(len(child), 3)
def test_get_artist(self):
# dataset should be improved to have tracks by a different artist than the album's artist
self._make_request("getArtist", error=10)

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2017 Alban 'spl0k' Féron
# Copyright (C) 2017-2022 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
@ -21,6 +21,7 @@ class SearchTestCase(ApiTestBase):
with db_session:
root = Folder(root=True, name="Root folder", path="tests/assets")
Folder(root=True, name="Empty", path="/tmp")
for letter in "ABC":
folder = Folder(
@ -58,7 +59,7 @@ class SearchTestCase(ApiTestBase):
commit()
self.assertEqual(Folder.select().count(), 10)
self.assertEqual(Folder.select().count(), 11)
self.assertEqual(Artist.select().count(), 3)
self.assertEqual(Album.select().count(), 6)
self.assertEqual(Track.select().count(), 18)
@ -196,6 +197,10 @@ class SearchTestCase(ApiTestBase):
self._make_request("search2", {"query": "a", "albumOffset": "sstring"}, error=0)
self._make_request("search2", {"query": "a", "songCount": "string"}, error=0)
self._make_request("search2", {"query": "a", "songOffset": "sstring"}, error=0)
self._make_request(
"search2", {"query": "a", "musicFolderId": "sstring"}, error=0
)
self._make_request("search2", {"query": "a", "musicFolderId": -2}, error=70)
# no search
self._make_request("search2", error=10)
@ -288,6 +293,17 @@ class SearchTestCase(ApiTestBase):
self.assertNotIn(song, songs)
songs.append(song)
# root filtering
_, child = self._make_request(
"search2", {"query": "One", "musicFolderId": 1}, tag="searchResult2"
)
self.assertEqual(len(self._xpath(child, "./song")), 6)
_, child = self._make_request(
"search2", {"query": "One", "musicFolderId": 2}, tag="searchResult2"
)
self.assertEqual(len(self._xpath(child, "./song")), 0)
# Almost identical as above. Test dataset (and tests) should probably be changed
# to have folders that don't share names with artists or albums
def test_search3(self):
@ -300,6 +316,10 @@ class SearchTestCase(ApiTestBase):
self._make_request("search3", {"query": "a", "albumOffset": "sstring"}, error=0)
self._make_request("search3", {"query": "a", "songCount": "string"}, error=0)
self._make_request("search3", {"query": "a", "songOffset": "sstring"}, error=0)
self._make_request(
"search3", {"query": "a", "musicFolderId": "sstring"}, error=0
)
self._make_request("search3", {"query": "a", "musicFolderId": -2}, error=70)
# no search
self._make_request("search3", error=10)
@ -392,6 +412,17 @@ class SearchTestCase(ApiTestBase):
self.assertNotIn(song, songs)
songs.append(song)
# root filtering
_, child = self._make_request(
"search3", {"query": "One", "musicFolderId": 1}, tag="searchResult3"
)
self.assertEqual(len(self._xpath(child, "./song")), 6)
_, child = self._make_request(
"search3", {"query": "One", "musicFolderId": 2}, tag="searchResult3"
)
self.assertEqual(len(self._xpath(child, "./song")), 0)
if __name__ == "__main__":
unittest.main()

View File

@ -4,7 +4,7 @@
targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified"
elementFormDefault="qualified"
version="1.10.2">
version="1.12.0">
<xs:element name="subsonic-response" type="sub:Response"/>
@ -39,9 +39,14 @@
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" minOccurs="1" maxOccurs="1"/>
<xs:element name="bookmarks" type="sub:Bookmarks" minOccurs="1" maxOccurs="1"/>
<xs:element name="playQueue" type="sub:PlayQueue" minOccurs="1" maxOccurs="1"/>
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo" type="sub:ArtistInfo" minOccurs="1" maxOccurs="1"/>
<xs:element name="artistInfo2" type="sub:ArtistInfo2" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs" type="sub:SimilarSongs" minOccurs="1" maxOccurs="1"/>
<xs:element name="similarSongs2" type="sub:SimilarSongs2" minOccurs="1" maxOccurs="1"/>
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
@ -292,6 +297,7 @@
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
</xs:complexType>
<xs:complexType name="PlaylistWithSongs">
@ -426,6 +432,17 @@
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType>
<xs:complexType name="PlayQueue">
<xs:sequence>
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="current" type="xs:int" use="optional"/> <!-- ID of currently playing track -->
<xs:attribute name="position" type="xs:long" use="optional"/> <!-- Position in milliseconds of currently playing track -->
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="changed" type="xs:dateTime" use="required"/>
<xs:attribute name="changedBy" type="xs:string" use="required"/> <!-- Name of client app -->
</xs:complexType>
<xs:complexType name="Shares">
<xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
@ -454,6 +471,49 @@
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfoBase">
<xs:sequence>
<xs:element name="biography" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="musicBrainzId" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="lastFmUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="smallImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="mediumImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="largeImageUrl" type="xs:string" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ArtistInfo">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="ArtistInfo2">
<xs:complexContent>
<xs:extension base="sub:ArtistInfoBase">
<xs:sequence>
<xs:element name="similarArtist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:complexType name="SimilarSongs">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="SimilarSongs2">
<xs:sequence>
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Starred2">
<xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
@ -476,6 +536,9 @@
</xs:complexType>
<xs:complexType name="User">
<xs:sequence>
<xs:element name="folder" type="xs:int" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.12.0 -->
</xs:sequence>
<xs:attribute name="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->