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 * [Last.fm][lastfm] scrobbling
* Jukebox mode * 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]. details, go check the [API implementation status][docs-api].
[subsonic]: http://www.subsonic.org/ [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 1.16.0 (Subsonic 6.1.2). Here you'll find details about which API features
Supysonic support, plan on supporting, or won't. 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 The following information was gathered by *diff*-ing various snapshots of the
`Subsonic API page`__. `Subsonic API page`__.
@ -227,7 +227,7 @@ Browsing
================= ====== = ================= ====== =
Parameter Vers. Parameter Vers.
================= ====== = ================= ====== =
``musicFolderId`` 1.14.0 📅 ``musicFolderId`` 1.14.0 ✔️
================= ====== = ================= ====== =
.. _getArtist: .. _getArtist:
@ -418,7 +418,7 @@ Album/song lists
``fromYear`` ✔️ ``fromYear`` ✔️
``toYear`` ✔️ ``toYear`` ✔️
``genre`` ✔️ ``genre`` ✔️
``musicFolderId`` 1.12.0 📅 ``musicFolderId`` 1.12.0 ✔️
================= ====== = ================= ====== =
.. versionadded:: 1.10.1 .. versionadded:: 1.10.1
@ -441,7 +441,7 @@ Album/song lists
``fromYear`` ✔️ ``fromYear`` ✔️
``toYear`` ✔️ ``toYear`` ✔️
``genre`` ✔️ ``genre`` ✔️
``musicFolderId`` 1.12.0 📅 ``musicFolderId`` 1.12.0 ✔️
================= ====== = ================= ====== =
.. versionadded:: 1.10.1 .. versionadded:: 1.10.1
@ -479,7 +479,7 @@ Album/song lists
``genre`` 1.9.0 ✔️ ``genre`` 1.9.0 ✔️
``count`` 1.9.0 ✔️ ``count`` 1.9.0 ✔️
``offset`` 1.9.0 ✔️ ``offset`` 1.9.0 ✔️
``musicFolderId`` 1.12.0 📅 ``musicFolderId`` 1.12.0 ✔️
================= ====== = ================= ====== =
.. _getNowPlaying: .. _getNowPlaying:
@ -500,7 +500,7 @@ Album/song lists
================= ====== = ================= ====== =
Parameter Vers. Parameter Vers.
================= ====== = ================= ====== =
``musicFolderId`` 1.12.0 📅 ``musicFolderId`` 1.12.0 ✔️
================= ====== = ================= ====== =
.. _getStarred2: .. _getStarred2:
@ -514,7 +514,7 @@ Album/song lists
================= ====== = ================= ====== =
Parameter Vers. Parameter Vers.
================= ====== = ================= ====== =
``musicFolderId`` 1.12.0 📅 ``musicFolderId`` 1.12.0 ✔️
================= ====== = ================= ====== =
Searching Searching
@ -558,7 +558,7 @@ Searching
``albumOffset`` ✔️ ``albumOffset`` ✔️
``songCount`` ✔️ ``songCount`` ✔️
``songOffset`` ✔️ ``songOffset`` ✔️
``musicFolderId`` 1.12.0 📅 ``musicFolderId`` 1.12.0 ✔️
================= ====== = ================= ====== =
.. _search3: .. _search3:
@ -579,7 +579,7 @@ Searching
``albumOffset`` ✔️ ``albumOffset`` ✔️
``songCount`` ✔️ ``songCount`` ✔️
``songOffset`` ✔️ ``songOffset`` ✔️
``musicFolderId`` 1.12.0 📅 ``musicFolderId`` 1.12.0 ✔️
================= ====== = ================= ====== =
Playlists Playlists

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic. # This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API. # 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. # Distributed under terms of the GNU AGPLv3 license.
@ -17,7 +17,7 @@ from pony.orm import commit
from ..db import ClientPrefs, Folder from ..db import ClientPrefs, Folder
from ..managers.user import UserManager from ..managers.user import UserManager
from .exceptions import GenericError, Unauthorized from .exceptions import GenericError, Unauthorized, NotFound
from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter
api = Blueprint("api", __name__) api = Blueprint("api", __name__)
@ -119,6 +119,22 @@ def get_entity_id(cls, eid):
raise GenericError("Invalid ID") 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 .errors import *
from .system import * from .system import *

View File

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

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic. # This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API. # 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. # Distributed under terms of the GNU AGPLv3 license.
@ -9,11 +9,11 @@ import re
import string import string
from flask import current_app, request 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 ..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") @api_routing("/getMusicFolders")
@ -80,12 +80,7 @@ def list_indexes():
if musicFolderId is None: if musicFolderId is None:
folders = Folder.select(lambda f: f.root)[:] folders = Folder.select(lambda f: f.root)[:]
else: else:
mfid = get_entity_id(Folder, musicFolderId) folders = [get_root_folder(musicFolderId)]
folder = Folder[mfid]
if not folder.root:
raise ObjectNotFound(Folder, mfid)
folders = [folder]
last_modif = max(f.last_scan for f in folders) last_modif = max(f.last_scan for f in folders)
if ifModifiedSince is not None and last_modif < ifModifiedSince: if ifModifiedSince is not None and last_modif < ifModifiedSince:
@ -153,8 +148,14 @@ def list_genres():
@api_routing("/getArtists") @api_routing("/getArtists")
def list_artists(): def list_artists():
# According to the API page, there are no parameters? mfid = request.values.get("musicFolderId")
indexes = build_indexes(Artist.select())
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( return request.formatter(
"artists", "artists",
{ {

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic. # This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API. # 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. # 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 ..db import Folder, Track, Artist, Album
from . import api_routing from . import api_routing, get_root_folder
from .exceptions import MissingParameter from .exceptions import MissingParameter
@ -93,6 +93,7 @@ def new_search():
album_offset, album_offset,
song_count, song_count,
song_offset, song_offset,
mfid,
) = map( ) = map(
request.values.get, request.values.get,
( (
@ -102,6 +103,7 @@ def new_search():
"albumOffset", "albumOffset",
"songCount", "songCount",
"songOffset", "songOffset",
"musicFolderId",
), ),
) )
@ -111,14 +113,20 @@ def new_search():
album_offset = int(album_offset) if album_offset else 0 album_offset = int(album_offset) if album_offset else 0
song_count = int(song_count) if song_count else 20 song_count = int(song_count) if song_count else 20
song_offset = int(song_offset) if song_offset else 0 song_offset = int(song_offset) if song_offset else 0
root = get_root_folder(mfid)
artists = select( artists = select(t.folder.parent for t in Track if query in t.folder.parent.name)
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)
).limit(artist_count, artist_offset) songs = Track.select(lambda t: query in t.title)
albums = select(t.folder for t in Track if query in t.folder.name).limit(
album_count, album_offset if root is not None:
) artists = artists.where(lambda t: t.root_folder == root)
songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset) 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( return request.formatter(
"searchResult2", "searchResult2",
@ -145,6 +153,7 @@ def search_id3():
album_offset, album_offset,
song_count, song_count,
song_offset, song_offset,
mfid,
) = map( ) = map(
request.values.get, request.values.get,
( (
@ -154,6 +163,7 @@ def search_id3():
"albumOffset", "albumOffset",
"songCount", "songCount",
"songOffset", "songOffset",
"musicFolderId",
), ),
) )
@ -163,12 +173,20 @@ def search_id3():
album_offset = int(album_offset) if album_offset else 0 album_offset = int(album_offset) if album_offset else 0
song_count = int(song_count) if song_count else 20 song_count = int(song_count) if song_count else 20
song_offset = int(song_offset) if song_offset else 0 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( artists = Artist.select(lambda a: query in a.name)
artist_count, artist_offset albums = Album.select(lambda a: query in a.name)
) songs = Track.select(lambda t: query in t.title)
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) 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( return request.formatter(
"searchResult3", "searchResult3",

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# This file is part of Supysonic. # This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API. # 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. # Distributed under terms of the GNU AGPLv3 license.
@ -21,6 +21,7 @@ class SearchTestCase(ApiTestBase):
with db_session: with db_session:
root = Folder(root=True, name="Root folder", path="tests/assets") root = Folder(root=True, name="Root folder", path="tests/assets")
Folder(root=True, name="Empty", path="/tmp")
for letter in "ABC": for letter in "ABC":
folder = Folder( folder = Folder(
@ -58,7 +59,7 @@ class SearchTestCase(ApiTestBase):
commit() commit()
self.assertEqual(Folder.select().count(), 10) self.assertEqual(Folder.select().count(), 11)
self.assertEqual(Artist.select().count(), 3) self.assertEqual(Artist.select().count(), 3)
self.assertEqual(Album.select().count(), 6) self.assertEqual(Album.select().count(), 6)
self.assertEqual(Track.select().count(), 18) 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", "albumOffset": "sstring"}, error=0)
self._make_request("search2", {"query": "a", "songCount": "string"}, 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", "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 # no search
self._make_request("search2", error=10) self._make_request("search2", error=10)
@ -288,6 +293,17 @@ class SearchTestCase(ApiTestBase):
self.assertNotIn(song, songs) self.assertNotIn(song, songs)
songs.append(song) 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 # Almost identical as above. Test dataset (and tests) should probably be changed
# to have folders that don't share names with artists or albums # to have folders that don't share names with artists or albums
def test_search3(self): 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", "albumOffset": "sstring"}, error=0)
self._make_request("search3", {"query": "a", "songCount": "string"}, 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", "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 # no search
self._make_request("search3", error=10) self._make_request("search3", error=10)
@ -392,6 +412,17 @@ class SearchTestCase(ApiTestBase):
self.assertNotIn(song, songs) self.assertNotIn(song, songs)
songs.append(song) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -4,7 +4,7 @@
targetNamespace="http://subsonic.org/restapi" targetNamespace="http://subsonic.org/restapi"
attributeFormDefault="unqualified" attributeFormDefault="unqualified"
elementFormDefault="qualified" elementFormDefault="qualified"
version="1.10.2"> version="1.12.0">
<xs:element name="subsonic-response" type="sub:Response"/> <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="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
<xs:element name="internetRadioStations" type="sub:InternetRadioStations" 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="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="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
<xs:element name="starred" type="sub:Starred" 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="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:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
</xs:choice> </xs:choice>
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/> <xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
@ -287,11 +292,12 @@
<xs:attribute name="id" type="xs:string" use="required"/> <xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="name" type="xs:string" use="required"/> <xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0--> <xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0--> <xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0--> <xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0--> <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="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="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>
<xs:complexType name="PlaylistWithSongs"> <xs:complexType name="PlaylistWithSongs">
@ -426,6 +432,17 @@
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <xs:attribute name="changed" type="xs:dateTime" use="required"/>
</xs:complexType> </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:complexType name="Shares">
<xs:sequence> <xs:sequence>
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/> <xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
@ -454,6 +471,49 @@
</xs:sequence> </xs:sequence>
</xs:complexType> </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:complexType name="Starred2">
<xs:sequence> <xs:sequence>
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/> <xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
@ -476,8 +536,11 @@
</xs:complexType> </xs:complexType>
<xs:complexType name="User"> <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="username" type="xs:string" use="required"/>
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 --> <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 --> <xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
<xs:attribute name="adminRole" type="xs:boolean" use="required"/> <xs:attribute name="adminRole" type="xs:boolean" use="required"/>
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/> <xs:attribute name="settingsRole" type="xs:boolean" use="required"/>