1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-23 01:16:18 +00:00

Py3: str/bytes, iterators, etc.

It seems to work on Python 3 now!
Ref #75
This commit is contained in:
spl0k 2018-01-11 23:08:53 +01:00
parent 1a79fe3d70
commit 7edb246b1e
17 changed files with 132 additions and 70 deletions

View File

@ -61,7 +61,7 @@ def decode_password(password):
return password return password
try: try:
return binascii.unhexlify(password[4:]).decode('utf-8') return binascii.unhexlify(password[4:].encode('utf-8')).decode('utf-8')
except: except:
return password return password
@ -141,14 +141,19 @@ class ResponseHelper:
if not isinstance(d, dict): if not isinstance(d, dict):
raise TypeError('Expecting a dict') raise TypeError('Expecting a dict')
keys_to_remove = []
for key, value in d.items(): for key, value in d.items():
if isinstance(value, dict): if isinstance(value, dict):
d[key] = ResponseHelper.remove_empty_lists(value) d[key] = ResponseHelper.remove_empty_lists(value)
elif isinstance(value, list): elif isinstance(value, list):
if len(value) == 0: if len(value) == 0:
del d[key] keys_to_remove.append(key)
else: else:
d[key] = [ ResponseHelper.remove_empty_lists(item) if isinstance(item, dict) else item for item in value ] d[key] = [ ResponseHelper.remove_empty_lists(item) if isinstance(item, dict) else item for item in value ]
for key in keys_to_remove:
del d[key]
return d return d
@staticmethod @staticmethod
@ -178,7 +183,7 @@ class ResponseHelper:
elem = ElementTree.Element('subsonic-response') elem = ElementTree.Element('subsonic-response')
ResponseHelper.dict2xml(elem, ret) ResponseHelper.dict2xml(elem, ret)
return minidom.parseString(ElementTree.tostring(elem)).toprettyxml(indent = ' ', encoding = 'UTF-8') return minidom.parseString(ElementTree.tostring(elem)).toprettyxml(indent = ' ')
@staticmethod @staticmethod
def dict2xml(elem, dictionary): def dict2xml(elem, dictionary):

View File

@ -82,7 +82,7 @@ def try_unstar(starred_cls, eid):
def merge_errors(errors): def merge_errors(errors):
error = None error = None
errors = filter(None, errors) errors = [ e for e in errors if e ]
if len(errors) == 1: if len(errors) == 1:
error = errors[0] error = errors[0]
elif len(errors) > 1: elif len(errors) > 1:
@ -149,7 +149,7 @@ def rate():
except: except:
return request.error_formatter(0, 'Invalid parameter') return request.error_formatter(0, 'Invalid parameter')
if not rating in xrange(6): if not 0 <= rating <= 5:
return request.error_formatter(0, 'rating must be between 0 and 5 (inclusive)') return request.error_formatter(0, 'rating must be between 0 and 5 (inclusive)')
with db_session: with db_session:

View File

@ -84,9 +84,9 @@ def list_indexes():
indexes = dict() indexes = dict()
for artist in artists: for artist in artists:
index = artist.name[0].upper() index = artist.name[0].upper()
if index in map(str, xrange(10)): if index in string.digits:
index = '#' index = '#'
elif index not in string.letters: elif index not in string.ascii_letters:
index = '?' index = '?'
if index not in indexes: if index not in indexes:
@ -132,9 +132,9 @@ def list_artists():
indexes = dict() indexes = dict()
for artist in Artist.select(): for artist in Artist.select():
index = artist.name[0].upper() if artist.name else '?' index = artist.name[0].upper() if artist.name else '?'
if index in map(str, xrange(10)): if index in string.digits:
index = '#' index = '#'
elif index not in string.letters: elif index not in string.ascii_letters:
index = '?' index = '?'
if index not in indexes: if index not in indexes:

View File

@ -40,8 +40,10 @@ def prepare_transcoding_cmdline(base_cmdline, input_file, input_format, output_f
if not base_cmdline: if not base_cmdline:
return None return None
ret = base_cmdline.split() ret = base_cmdline.split()
for i in xrange(len(ret)): ret = [
ret[i] = ret[i].replace('%srcpath', input_file).replace('%srcfmt', input_format).replace('%outfmt', output_format).replace('%outrate', str(output_bitrate)) part.replace('%srcpath', input_file).replace('%srcfmt', input_format).replace('%outfmt', output_format).replace('%outrate', str(output_bitrate))
for part in ret
]
return ret return ret
@app.route('/rest/stream.view', methods = [ 'GET', 'POST' ]) @app.route('/rest/stream.view', methods = [ 'GET', 'POST' ])

View File

@ -71,9 +71,8 @@ def create_playlist():
songs = request.values.getlist('songId') songs = request.values.getlist('songId')
try: try:
playlist_id = uuid.UUID(playlist_id) if playlist_id else None playlist_id = uuid.UUID(playlist_id) if playlist_id else None
songs = map(uuid.UUID, songs)
except: except:
return request.error_formatter(0, 'Invalid parameter') return request.error_formatter(0, 'Invalid playlist id')
if playlist_id: if playlist_id:
try: try:
@ -92,15 +91,18 @@ def create_playlist():
else: else:
return request.error_formatter(10, 'Missing playlist id or name') return request.error_formatter(10, 'Missing playlist id or name')
for sid in songs:
try: try:
songs = map(uuid.UUID, songs)
for sid in songs:
track = Track[sid] track = Track[sid]
playlist.add(track)
except ValueError:
rollback()
return request.error_formatter(0, 'Invalid song id')
except ObjectNotFound: except ObjectNotFound:
rollback() rollback()
return request.error_formatter(70, 'Unknown song') return request.error_formatter(70, 'Unknown song')
playlist.add(track)
return request.formatter(dict()) return request.formatter(dict())
@app.route('/rest/deletePlaylist.view', methods = [ 'GET', 'POST' ]) @app.route('/rest/deletePlaylist.view', methods = [ 'GET', 'POST' ])
@ -129,11 +131,6 @@ def update_playlist():
playlist = res playlist = res
name, comment, public = map(request.values.get, [ 'name', 'comment', 'public' ]) name, comment, public = map(request.values.get, [ 'name', 'comment', 'public' ])
to_add, to_remove = map(request.values.getlist, [ 'songIdToAdd', 'songIndexToRemove' ]) to_add, to_remove = map(request.values.getlist, [ 'songIdToAdd', 'songIndexToRemove' ])
try:
to_add = map(uuid.UUID, to_add)
to_remove = map(int, to_remove)
except:
return request.error_formatter(0, 'Invalid parameter')
if name: if name:
playlist.name = name playlist.name = name
@ -142,14 +139,19 @@ def update_playlist():
if public: if public:
playlist.public = public in (True, 'True', 'true', 1, '1') playlist.public = public in (True, 'True', 'true', 1, '1')
for sid in to_add:
try: try:
to_add = map(uuid.UUID, to_add)
to_remove = map(int, to_remove)
for sid in to_add:
track = Track[sid] track = Track[sid]
except ObjectNotFound:
return request.error_formatter(70, 'Unknown song')
playlist.add(track) playlist.add(track)
playlist.remove_at_indexes(to_remove) playlist.remove_at_indexes(to_remove)
except ValueError:
return request.error_formatter(0, 'Invalid parameter')
except ObjectNotFound:
return request.error_formatter(70, 'Unknown song')
return request.formatter(dict()) return request.formatter(dict())

View File

@ -94,11 +94,11 @@ def new_search():
albums = select(t.folder for t in Track if query in t.folder.name).limit(album_count, album_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) songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset)
return request.formatter(dict(searchResult2 = OrderedDict( return request.formatter(dict(searchResult2 = OrderedDict((
artist = [ dict(id = str(a.id), name = a.name) for a in artists ], ('artist', [ dict(id = str(a.id), name = a.name) for a in artists ]),
album = [ f.as_subsonic_child(request.user) for f in albums ], ('album', [ f.as_subsonic_child(request.user) for f in albums ]),
song = [ t.as_subsonic_child(request.user, request.client) for t in songs ] ('song', [ t.as_subsonic_child(request.user, request.client) for t in songs ])
))) ))))
@app.route('/rest/search3.view', methods = [ 'GET', 'POST' ]) @app.route('/rest/search3.view', methods = [ 'GET', 'POST' ])
def search_id3(): def search_id3():
@ -123,9 +123,9 @@ def search_id3():
albums = Album.select(lambda a: query in a.name).limit(album_count, album_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) songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset)
return request.formatter(dict(searchResult3 = OrderedDict( return request.formatter(dict(searchResult3 = OrderedDict((
artist = [ a.as_subsonic_artist(request.user) for a in artists ], ('artist', [ a.as_subsonic_artist(request.user) for a in artists ]),
album = [ a.as_subsonic_album(request.user) for a in albums ], ('album', [ a.as_subsonic_album(request.user) for a in albums ]),
song = [ t.as_subsonic_child(request.user, request.client) for t in songs ] ('song', [ t.as_subsonic_child(request.user, request.client) for t in songs ])
))) ))))

View File

@ -24,10 +24,13 @@ from .py23 import strtype
class LastFm: class LastFm:
def __init__(self, config, user, logger): def __init__(self, config, user, logger):
if config['api_key'] is not None and config['secret'] is not None:
self.__api_key = config['api_key'] self.__api_key = config['api_key']
self.__api_secret = config['secret'] self.__api_secret = config['secret'].encode('utf-8')
self.__enabled = True
else:
self.__enabled = False
self.__user = user self.__user = user
self.__enabled = self.__api_key is not None and self.__api_secret is not None
self.__logger = logger self.__logger = logger
def link_account(self, token): def link_account(self, token):
@ -75,10 +78,9 @@ class LastFm:
sig_str = b'' sig_str = b''
for k, v in sorted(kwargs.items()): for k, v in sorted(kwargs.items()):
if isinstance(v, strtype): k = k.encode('utf-8')
sig_str += k + v.encode('utf-8') v = v.encode('utf-8') if isinstance(v, strtype) else str(v).encode('utf-8')
else: sig_str += k + v
sig_str += k + str(v)
sig = hashlib.md5(sig_str + self.__api_secret).hexdigest() sig = hashlib.md5(sig_str + self.__api_secret).hexdigest()
kwargs['api_sig'] = sig kwargs['api_sig'] = sig

View File

@ -136,6 +136,6 @@ class UserManager:
@staticmethod @staticmethod
def __encrypt_password(password, salt = None): def __encrypt_password(password, salt = None):
if salt is None: if salt is None:
salt = ''.join(random.choice(string.printable.strip()) for i in xrange(6)) salt = ''.join(random.choice(string.printable.strip()) for _ in range(6))
return hashlib.sha1(salt.encode('utf-8') + password.encode('utf-8')).hexdigest(), salt return hashlib.sha1(salt.encode('utf-8') + password.encode('utf-8')).hexdigest(), salt

View File

@ -154,7 +154,7 @@ class Scanner:
trdict['genre'] = self.__try_read_tag(tag, 'genre') trdict['genre'] = self.__try_read_tag(tag, 'genre')
trdict['duration'] = int(tag.info.length) trdict['duration'] = int(tag.info.length)
trdict['bitrate'] = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) / 1000 trdict['bitrate'] = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) // 1000
trdict['content_type'] = mimetypes.guess_type(path, False)[0] or 'application/octet-stream' trdict['content_type'] = mimetypes.guess_type(path, False)[0] or 'application/octet-stream'
trdict['last_modification'] = int(os.path.getmtime(path)) trdict['last_modification'] = int(os.path.getmtime(path))

View File

@ -5,38 +5,42 @@
# 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-2018 Alban 'spl0k' Féron
# 2017 Óscar García Amor # 2017 Óscar García Amor
# #
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
import base64 import base64
import binascii
import simplejson import simplejson
from xml.etree import ElementTree from xml.etree import ElementTree
from ..testbase import TestBase from ..testbase import TestBase
from ..utils import hexlify
class ApiSetupTestCase(TestBase): class ApiSetupTestCase(TestBase):
__with_api__ = True __with_api__ = True
def setUp(self):
super(ApiSetupTestCase, self).setUp()
self._patch_client()
def __basic_auth_get(self, username, password): def __basic_auth_get(self, username, password):
hashed = base64.b64encode('{}:{}'.format(username, password)) hashed = base64.b64encode('{}:{}'.format(username, password).encode('utf-8'))
headers = { 'Authorization': 'Basic ' + hashed } headers = { 'Authorization': 'Basic ' + hashed.decode('utf-8') }
return self.client.get('/rest/ping.view', headers = headers, query_string = { 'c': 'tests' }) return self.client.get('/rest/ping.view', headers = headers, query_string = { 'c': 'tests' })
def __query_params_auth_get(self, username, password): def __query_params_auth_get(self, username, password):
return self.client.get('/rest/ping.view', query_string = { 'c': 'tests', 'u': username, 'p': password }) return self.client.get('/rest/ping.view', query_string = { 'c': 'tests', 'u': username, 'p': password })
def __query_params_auth_enc_get(self, username, password): def __query_params_auth_enc_get(self, username, password):
return self.__query_params_auth_get(username, 'enc:' + binascii.hexlify(password)) return self.__query_params_auth_get(username, 'enc:' + hexlify(password))
def __form_auth_post(self, username, password): def __form_auth_post(self, username, password):
return self.client.post('/rest/ping.view', data = { 'c': 'tests', 'u': username, 'p': password }) return self.client.post('/rest/ping.view', data = { 'c': 'tests', 'u': username, 'p': password })
def __form_auth_enc_post(self, username, password): def __form_auth_enc_post(self, username, password):
return self.__form_auth_post(username, 'enc:' + binascii.hexlify(password)) return self.__form_auth_post(username, 'enc:' + hexlify(password))
def __test_auth(self, method): def __test_auth(self, method):
# non-existent user # non-existent user
@ -66,7 +70,7 @@ class ApiSetupTestCase(TestBase):
self.__test_auth(self.__basic_auth_get) self.__test_auth(self.__basic_auth_get)
# Shouldn't accept 'enc:' passwords # Shouldn't accept 'enc:' passwords
rv = self.__basic_auth_get('alice', 'enc:' + binascii.hexlify('Alic3')) rv = self.__basic_auth_get('alice', 'enc:' + hexlify('Alic3'))
self.assertEqual(rv.status_code, 401) self.assertEqual(rv.status_code, 401)
self.assertIn('status="failed"', rv.data) self.assertIn('status="failed"', rv.data)
self.assertIn('code="40"', rv.data) self.assertIn('code="40"', rv.data)

View File

@ -5,7 +5,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-2018 Alban 'spl0k' Féron
# #
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
@ -140,7 +140,7 @@ class PlaylistTestCase(ApiTestBase):
# create more useful playlist # create more useful playlist
with db_session: with db_session:
songs = { s.title: str(s.id) for s in Track.select() } songs = { s.title: str(s.id) for s in Track.select() }
self._make_request('createPlaylist', { 'name': 'songs', 'songId': map(lambda s: songs[s], [ 'Three', 'One', 'Two' ]) }, skip_post = True) self._make_request('createPlaylist', { 'name': 'songs', 'songId': list(map(lambda s: songs[s], [ 'Three', 'One', 'Two' ])) }, skip_post = True)
with db_session: with db_session:
playlist = Playlist.get(name = 'songs') playlist = Playlist.get(name = 'songs')
self.assertIsNotNone(playlist) self.assertIsNotNone(playlist)

View File

@ -5,13 +5,12 @@
# 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-2018 Alban 'spl0k' Féron
# 2017 Óscar García Amor # 2017 Óscar García Amor
# #
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
import binascii from ..utils import hexlify
from .apitestbase import ApiTestBase from .apitestbase import ApiTestBase
class UserTestCase(ApiTestBase): class UserTestCase(ApiTestBase):
@ -141,7 +140,7 @@ class UserTestCase(ApiTestBase):
self._make_request('changePassword', { 'username': 'alice', 'password': 'Alic3', 'u': 'alice', 'p': 'новыйпароль' }, skip_post = True) self._make_request('changePassword', { 'username': 'alice', 'password': 'Alic3', 'u': 'alice', 'p': 'новыйпароль' }, skip_post = True)
# non ASCII in hex encoded password # non ASCII in hex encoded password
self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:' + binascii.hexlify('новыйпароль') }, skip_post = True) self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:' + hexlify(u'новыйпароль') }, skip_post = True)
self._make_request('ping', { 'u': 'alice', 'p': 'новыйпароль' }) self._make_request('ping', { 'u': 'alice', 'p': 'новыйпароль' })
# new password starting with 'enc:' followed by non hex chars # new password starting with 'enc:' followed by non hex chars

View File

@ -5,7 +5,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-2017 Alban 'spl0k' Féron # Copyright (C) 2017-2018 Alban 'spl0k' Féron
# #
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
@ -147,7 +147,7 @@ class ScannerTestCase(unittest.TestCase):
self.assertEqual(db.Track.select().count(), 2) self.assertEqual(db.Track.select().count(), 2)
tf.seek(0, 0) tf.seek(0, 0)
tf.write('\x00' * 4096) tf.write(b'\x00' * 4096)
tf.truncate() tf.truncate()
self.scanner.scan(db.Folder[self.folderid]) self.scanner.scan(db.Folder[self.folderid])

View File

@ -4,7 +4,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-2018 Alban 'spl0k' Féron
# #
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
@ -13,6 +13,10 @@ from ..testbase import TestBase
class FrontendTestBase(TestBase): class FrontendTestBase(TestBase):
__with_webui__ = True __with_webui__ = True
def setUp(self):
super(FrontendTestBase, self).setUp()
self._patch_client()
def _login(self, username, password): def _login(self, username, password):
return self.client.post('/user/login', data = { 'user': username, 'password': password }, follow_redirects = True) return self.client.post('/user/login', data = { 'user': username, 'password': password }, follow_redirects = True)

View File

@ -5,7 +5,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-2017 Alban 'spl0k' Féron # Copyright (C) 2017 Alban 'spl0k' Féron
# #
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.

View File

@ -53,6 +53,32 @@ class TestConfig(DefaultConfig):
'mount_api': with_api 'mount_api': with_api
}) })
class MockResponse(object):
def __init__(self, response):
self.__status_code = response.status_code
self.__data = response.get_data(as_text = True)
self.__mimetype = response.mimetype
@property
def status_code(self):
return self.__status_code
@property
def data(self):
return self.__data
@property
def mimetype(self):
return self.__mimetype
def patch_method(f):
original = f
def patched(*args, **kwargs):
rv = original(*args, **kwargs)
return MockResponse(rv)
return patched
class TestBase(unittest.TestCase): class TestBase(unittest.TestCase):
__with_webui__ = False __with_webui__ = False
__with_api__ = False __with_api__ = False
@ -76,6 +102,10 @@ class TestBase(unittest.TestCase):
UserManager.add('alice', 'Alic3', 'test@example.com', True) UserManager.add('alice', 'Alic3', 'test@example.com', True)
UserManager.add('bob', 'B0b', 'bob@example.com', False) UserManager.add('bob', 'B0b', 'bob@example.com', False)
def _patch_client(self):
self.client.get = patch_method(self.client.get)
self.client.post = patch_method(self.client.post)
@staticmethod @staticmethod
def __should_unload_module(module): def __should_unload_module(module):
if module.startswith('supysonic'): if module.startswith('supysonic'):

14
tests/utils.py Normal file
View File

@ -0,0 +1,14 @@
# coding: utf-8
#
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2018 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
import binascii
def hexlify(s):
return binascii.hexlify(s.encode('utf-8')).decode('utf-8')