diff --git a/bin/supysonic-cli b/bin/supysonic-cli index 99ddd85..13df019 100755 --- a/bin/supysonic-cli +++ b/bin/supysonic-cli @@ -20,227 +20,230 @@ # along with this program. If not, see . import sys, cmd, argparse, getpass, time -from supysonic import config +from supysonic.config import IniConfig from supysonic.db import get_store, Folder, User from supysonic.managers.folder import FolderManager from supysonic.managers.user import UserManager from supysonic.scanner import Scanner class CLIParser(argparse.ArgumentParser): - def error(self, message): - self.print_usage(sys.stderr) - raise RuntimeError(message) + def error(self, message): + self.print_usage(sys.stderr) + raise RuntimeError(message) class CLI(cmd.Cmd): - prompt = "supysonic> " + prompt = "supysonic> " - def _make_do(self, command): - def method(obj, line): - try: - args = getattr(obj, command + '_parser').parse_args(line.split()) - except RuntimeError, e: - print >>sys.stderr, e - return + def _make_do(self, command): + def method(obj, line): + try: + args = getattr(obj, command + '_parser').parse_args(line.split()) + except RuntimeError, e: + print >>sys.stderr, e + return - if hasattr(obj.__class__, command + '_subparsers'): - try: - func = getattr(obj, '{}_{}'.format(command, args.action)) - except AttributeError: - return obj.default(line) - return func(** { key: vars(args)[key] for key in vars(args) if key != 'action' }) - else: - try: - func = getattr(obj, command) - except AttributeError: - return obj.default(line) - return func(**vars(args)) + if hasattr(obj.__class__, command + '_subparsers'): + try: + func = getattr(obj, '{}_{}'.format(command, args.action)) + except AttributeError: + return obj.default(line) + return func(** { key: vars(args)[key] for key in vars(args) if key != 'action' }) + else: + try: + func = getattr(obj, command) + except AttributeError: + return obj.default(line) + return func(**vars(args)) - return method + return method - def __init__(self, store): - cmd.Cmd.__init__(self) + def __init__(self, config): + cmd.Cmd.__init__(self) + self.__config = config - # Generate do_* and help_* methods - for parser_name in filter(lambda attr: attr.endswith('_parser') and '_' not in attr[:-7], dir(self.__class__)): - command = parser_name[:-7] + # Generate do_* and help_* methods + for parser_name in filter(lambda attr: attr.endswith('_parser') and '_' not in attr[:-7], dir(self.__class__)): + command = parser_name[:-7] - if not hasattr(self.__class__, 'do_' + command): - setattr(self.__class__, 'do_' + command, self._make_do(command)) + if not hasattr(self.__class__, 'do_' + command): + setattr(self.__class__, 'do_' + command, self._make_do(command)) - if hasattr(self.__class__, 'do_' + command) and not hasattr(self.__class__, 'help_' + command): - setattr(self.__class__, 'help_' + command, getattr(self.__class__, parser_name).print_help) - if hasattr(self.__class__, command + '_subparsers'): - for action, subparser in getattr(self.__class__, command + '_subparsers').choices.iteritems(): - setattr(self, 'help_{} {}'.format(command, action), subparser.print_help) + if hasattr(self.__class__, 'do_' + command) and not hasattr(self.__class__, 'help_' + command): + setattr(self.__class__, 'help_' + command, getattr(self.__class__, parser_name).print_help) + if hasattr(self.__class__, command + '_subparsers'): + for action, subparser in getattr(self.__class__, command + '_subparsers').choices.iteritems(): + setattr(self, 'help_{} {}'.format(command, action), subparser.print_help) - self.__store = store + self.__store = get_store(config.BASE['database_uri']) - def do_EOF(self, line): - return True + def do_EOF(self, line): + return True - do_exit = do_EOF + do_exit = do_EOF - def default(self, line): - print 'Unknown command %s' % line.split()[0] - self.do_help(None) + def default(self, line): + print 'Unknown command %s' % line.split()[0] + self.do_help(None) - def postloop(self): - print + def postloop(self): + print - def completedefault(self, text, line, begidx, endidx): - command = line.split()[0] - parsers = getattr(self.__class__, command + '_subparsers', None) - if not parsers: - return [] + def completedefault(self, text, line, begidx, endidx): + command = line.split()[0] + parsers = getattr(self.__class__, command + '_subparsers', None) + if not parsers: + return [] - num_words = len(line[len(command):begidx].split()) - if num_words == 0: - return [ a for a in parsers.choices.keys() if a.startswith(text) ] - return [] + num_words = len(line[len(command):begidx].split()) + if num_words == 0: + return [ a for a in parsers.choices.keys() if a.startswith(text) ] + return [] - folder_parser = CLIParser(prog = 'folder', add_help = False) - folder_subparsers = folder_parser.add_subparsers(dest = 'action') - folder_subparsers.add_parser('list', help = 'Lists folders', add_help = False) - folder_add_parser = folder_subparsers.add_parser('add', help = 'Adds a folder', add_help = False) - folder_add_parser.add_argument('name', help = 'Name of the folder to add') - folder_add_parser.add_argument('path', help = 'Path to the directory pointed by the folder') - folder_del_parser = folder_subparsers.add_parser('delete', help = 'Deletes a folder', add_help = False) - folder_del_parser.add_argument('name', help = 'Name of the folder to delete') - folder_scan_parser = folder_subparsers.add_parser('scan', help = 'Run a scan on specified folders', add_help = False) - folder_scan_parser.add_argument('folders', metavar = 'folder', nargs = '*', help = 'Folder(s) to be scanned. If ommitted, all folders are scanned') - folder_scan_parser.add_argument('-f', '--force', action = 'store_true', help = "Force scan of already know files even if they haven't changed") + folder_parser = CLIParser(prog = 'folder', add_help = False) + folder_subparsers = folder_parser.add_subparsers(dest = 'action') + folder_subparsers.add_parser('list', help = 'Lists folders', add_help = False) + folder_add_parser = folder_subparsers.add_parser('add', help = 'Adds a folder', add_help = False) + folder_add_parser.add_argument('name', help = 'Name of the folder to add') + folder_add_parser.add_argument('path', help = 'Path to the directory pointed by the folder') + folder_del_parser = folder_subparsers.add_parser('delete', help = 'Deletes a folder', add_help = False) + folder_del_parser.add_argument('name', help = 'Name of the folder to delete') + folder_scan_parser = folder_subparsers.add_parser('scan', help = 'Run a scan on specified folders', add_help = False) + folder_scan_parser.add_argument('folders', metavar = 'folder', nargs = '*', help = 'Folder(s) to be scanned. If ommitted, all folders are scanned') + folder_scan_parser.add_argument('-f', '--force', action = 'store_true', help = "Force scan of already know files even if they haven't changed") - def folder_list(self): - print 'Name\t\tPath\n----\t\t----' - print '\n'.join('{0: <16}{1}'.format(f.name, f.path) for f in self.__store.find(Folder, Folder.root == True)) + def folder_list(self): + print 'Name\t\tPath\n----\t\t----' + print '\n'.join('{0: <16}{1}'.format(f.name, f.path) for f in self.__store.find(Folder, Folder.root == True)) - def folder_add(self, name, path): - ret = FolderManager.add(self.__store, name, path) - if ret != FolderManager.SUCCESS: - print FolderManager.error_str(ret) - else: - print "Folder '{}' added".format(name) + def folder_add(self, name, path): + ret = FolderManager.add(self.__store, name, path) + if ret != FolderManager.SUCCESS: + print FolderManager.error_str(ret) + else: + print "Folder '{}' added".format(name) - def folder_delete(self, name): - ret = FolderManager.delete_by_name(self.__store, name) - if ret != FolderManager.SUCCESS: - print FolderManager.error_str(ret) - else: - print "Deleted folder '{}'".format(name) + def folder_delete(self, name): + ret = FolderManager.delete_by_name(self.__store, name) + if ret != FolderManager.SUCCESS: + print FolderManager.error_str(ret) + else: + print "Deleted folder '{}'".format(name) - def folder_scan(self, folders, force): + def folder_scan(self, folders, force): - class TimedProgressDisplay: - def __init__(self, name, interval = 5): - self.__name = name - self.__interval = interval - self.__last_display = 0 - self.__last_len = 0 + class TimedProgressDisplay: + def __init__(self, name, interval = 5): + self.__name = name + self.__interval = interval + self.__last_display = 0 + self.__last_len = 0 - def __call__(self, scanned, total): - if time.time() - self.__last_display > self.__interval or scanned == total: - if not self.__last_len: - sys.stdout.write("Scanning '{0}': ".format(self.__name)) + def __call__(self, scanned, total): + if time.time() - self.__last_display > self.__interval or scanned == total: + if not self.__last_len: + sys.stdout.write("Scanning '{0}': ".format(self.__name)) - progress = "{0}% ({1}/{2})".format((scanned * 100) / total, scanned, total) - sys.stdout.write('\b' * self.__last_len) - sys.stdout.write(progress) - sys.stdout.flush() + progress = "{0}% ({1}/{2})".format((scanned * 100) / total, scanned, total) + sys.stdout.write('\b' * self.__last_len) + sys.stdout.write(progress) + sys.stdout.flush() - self.__last_len = len(progress) - self.__last_display = time.time() + self.__last_len = len(progress) + self.__last_display = time.time() - scanner = Scanner(self.__store, force) - if folders: - folders = map(lambda n: self.__store.find(Folder, Folder.name == n, Folder.root == True).one() or n, folders) - if any(map(lambda f: isinstance(f, basestring), folders)): - print "No such folder(s): " + ' '.join(f for f in folders if isinstance(f, basestring)) - for folder in filter(lambda f: isinstance(f, Folder), folders): - scanner.scan(folder, TimedProgressDisplay(folder.name)) - else: - for folder in self.__store.find(Folder, Folder.root == True): - scanner.scan(folder, TimedProgressDisplay(folder.name)) + extensions = self.__config.BASE['scanner_extensions'] + if extensions: + extensions = extensions.split(' ') + scanner = Scanner(self.__store, force = force, extensions = extensions) + if folders: + folders = map(lambda n: self.__store.find(Folder, Folder.name == n, Folder.root == True).one() or n, folders) + if any(map(lambda f: isinstance(f, basestring), folders)): + print "No such folder(s): " + ' '.join(f for f in folders if isinstance(f, basestring)) + for folder in filter(lambda f: isinstance(f, Folder), folders): + scanner.scan(folder, TimedProgressDisplay(folder.name)) + else: + for folder in self.__store.find(Folder, Folder.root == True): + scanner.scan(folder, TimedProgressDisplay(folder.name)) - scanner.finish() - added, deleted = scanner.stats() - self.__store.commit() + scanner.finish() + added, deleted = scanner.stats() + self.__store.commit() - print - print "Scanning done" - print 'Added: %i artists, %i albums, %i tracks' % (added[0], added[1], added[2]) - print 'Deleted: %i artists, %i albums, %i tracks' % (deleted[0], deleted[1], deleted[2]) + print + print "Scanning done" + print 'Added: %i artists, %i albums, %i tracks' % (added[0], added[1], added[2]) + print 'Deleted: %i artists, %i albums, %i tracks' % (deleted[0], deleted[1], deleted[2]) - user_parser = CLIParser(prog = 'user', add_help = False) - user_subparsers = user_parser.add_subparsers(dest = 'action') - user_subparsers.add_parser('list', help = 'List users', add_help = False) - user_add_parser = user_subparsers.add_parser('add', help = 'Adds a user', add_help = False) - user_add_parser.add_argument('name', help = 'Name/login of the user to add') - user_add_parser.add_argument('-a', '--admin', action = 'store_true', help = 'Give admin rights to the new user') - user_add_parser.add_argument('-p', '--password', help = "Specifies the user's password") - user_add_parser.add_argument('-e', '--email', default = '', help = "Sets the user's email address") - user_del_parser = user_subparsers.add_parser('delete', help = 'Deletes a user', add_help = False) - user_del_parser.add_argument('name', help = 'Name/login of the user to delete') - user_admin_parser = user_subparsers.add_parser('setadmin', help = 'Enable/disable admin rights for a user', add_help = False) - user_admin_parser.add_argument('name', help = 'Name/login of the user to grant/revoke admin rights') - user_admin_parser.add_argument('--off', action = 'store_true', help = 'Revoke admin rights if present, grant them otherwise') - user_pass_parser = user_subparsers.add_parser('changepass', help = "Changes a user's password", add_help = False) - user_pass_parser.add_argument('name', help = 'Name/login of the user to which change the password') - user_pass_parser.add_argument('password', nargs = '?', help = 'New password') + user_parser = CLIParser(prog = 'user', add_help = False) + user_subparsers = user_parser.add_subparsers(dest = 'action') + user_subparsers.add_parser('list', help = 'List users', add_help = False) + user_add_parser = user_subparsers.add_parser('add', help = 'Adds a user', add_help = False) + user_add_parser.add_argument('name', help = 'Name/login of the user to add') + user_add_parser.add_argument('-a', '--admin', action = 'store_true', help = 'Give admin rights to the new user') + user_add_parser.add_argument('-p', '--password', help = "Specifies the user's password") + user_add_parser.add_argument('-e', '--email', default = '', help = "Sets the user's email address") + user_del_parser = user_subparsers.add_parser('delete', help = 'Deletes a user', add_help = False) + user_del_parser.add_argument('name', help = 'Name/login of the user to delete') + user_admin_parser = user_subparsers.add_parser('setadmin', help = 'Enable/disable admin rights for a user', add_help = False) + user_admin_parser.add_argument('name', help = 'Name/login of the user to grant/revoke admin rights') + user_admin_parser.add_argument('--off', action = 'store_true', help = 'Revoke admin rights if present, grant them otherwise') + user_pass_parser = user_subparsers.add_parser('changepass', help = "Changes a user's password", add_help = False) + user_pass_parser.add_argument('name', help = 'Name/login of the user to which change the password') + user_pass_parser.add_argument('password', nargs = '?', help = 'New password') - def user_list(self): - print 'Name\t\tAdmin\tEmail\n----\t\t-----\t-----' - print '\n'.join('{0: <16}{1}\t{2}'.format(u.name, '*' if u.admin else '', u.mail) for u in self.__store.find(User)) + def user_list(self): + print 'Name\t\tAdmin\tEmail\n----\t\t-----\t-----' + print '\n'.join('{0: <16}{1}\t{2}'.format(u.name, '*' if u.admin else '', u.mail) for u in self.__store.find(User)) - def user_add(self, name, admin, password, email): - if not password: - password = getpass.getpass() - confirm = getpass.getpass('Confirm password: ') - if password != confirm: - print >>sys.stderr, "Passwords don't match" - return - status = UserManager.add(self.__store, name, password, email, admin) - if status != UserManager.SUCCESS: - print >>sys.stderr, UserManager.error_str(status) + def user_add(self, name, admin, password, email): + if not password: + password = getpass.getpass() + confirm = getpass.getpass('Confirm password: ') + if password != confirm: + print >>sys.stderr, "Passwords don't match" + return + status = UserManager.add(self.__store, name, password, email, admin) + if status != UserManager.SUCCESS: + print >>sys.stderr, UserManager.error_str(status) - def user_delete(self, name): - user = self.__store.find(User, User.name == name).one() - if not user: - print >>sys.stderr, 'No such user' - else: - self.__store.remove(user) - self.__store.commit() - print "User '{}' deleted".format(name) + def user_delete(self, name): + user = self.__store.find(User, User.name == name).one() + if not user: + print >>sys.stderr, 'No such user' + else: + self.__store.remove(user) + self.__store.commit() + print "User '{}' deleted".format(name) - def user_setadmin(self, name, off): - user = self.__store.find(User, User.name == name).one() - if not user: - print >>sys.stderr, 'No such user' - else: - user.admin = not off - self.__store.commit() - print "{0} '{1}' admin rights".format('Revoked' if off else 'Granted', name) + def user_setadmin(self, name, off): + user = self.__store.find(User, User.name == name).one() + if not user: + print >>sys.stderr, 'No such user' + else: + user.admin = not off + self.__store.commit() + print "{0} '{1}' admin rights".format('Revoked' if off else 'Granted', name) - def user_changepass(self, name, password): - if not password: - password = getpass.getpass() - confirm = getpass.getpass('Confirm password: ') - if password != confirm: - print >>sys.stderr, "Passwords don't match" - return - status = UserManager.change_password2(self.__store, name, password) - if status != UserManager.SUCCESS: - print >>sys.stderr, UserManager.error_str(status) - else: - print "Successfully changed '{}' password".format(name) + def user_changepass(self, name, password): + if not password: + password = getpass.getpass() + confirm = getpass.getpass('Confirm password: ') + if password != confirm: + print >>sys.stderr, "Passwords don't match" + return + status = UserManager.change_password2(self.__store, name, password) + if status != UserManager.SUCCESS: + print >>sys.stderr, UserManager.error_str(status) + else: + print "Successfully changed '{}' password".format(name) if __name__ == "__main__": - if not config.check(): - sys.exit(1) + config = IniConfig.from_common_locations() - cli = CLI(get_store(config.get('base', 'database_uri'))) - if len(sys.argv) > 1: - cli.onecmd(' '.join(sys.argv[1:])) - else: - cli.cmdloop() + cli = CLI(config) + if len(sys.argv) > 1: + cli.onecmd(' '.join(sys.argv[1:])) + else: + cli.cmdloop() diff --git a/bin/supysonic-watcher b/bin/supysonic-watcher index f2b0856..c81de0e 100755 --- a/bin/supysonic-watcher +++ b/bin/supysonic-watcher @@ -19,9 +19,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from supysonic.config import IniConfig from supysonic.watcher import SupysonicWatcher if __name__ == "__main__": - watcher = SupysonicWatcher() + config = IniConfig.from_common_locations() + watcher = SupysonicWatcher(config) watcher.run() diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index bc07943..0d603f2 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -18,14 +18,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request +from flask import request, current_app as app from xml.etree import ElementTree from xml.dom import minidom import simplejson import uuid import binascii -from supysonic.web import app, store +from supysonic.web import store from supysonic.managers.user import UserManager @app.before_request diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 5244d41..0c0dcf5 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -18,14 +18,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request +from flask import request, current_app as app from storm.expr import Desc, Avg, Min, Max from storm.info import ClassAlias from datetime import timedelta import random import uuid -from supysonic.web import app, store +from supysonic.web import store from supysonic.db import Folder, Artist, Album, Track, RatingFolder, StarredFolder, StarredArtist, StarredAlbum, StarredTrack, User from supysonic.db import now diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index b0d1436..9d84d82 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -20,8 +20,9 @@ import time import uuid -from flask import request -from supysonic.web import app, store +from flask import request, current_app as app + +from supysonic.web import store from . import get_entity from supysonic.lastfm import LastFm from supysonic.db import Track, Album, Artist, Folder @@ -187,7 +188,7 @@ def scrobble(): else: t = int(time.time()) - lfm = LastFm(request.user, app.logger) + lfm = LastFm(app.config['LASTFM'], request.user, app.logger) if submission in (None, '', True, 'true', 'True', 1, '1'): lfm.scrobble(res, t) diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index 53ba84e..138ee13 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -18,8 +18,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request -from supysonic.web import app, store +from flask import request, current_app as app +from supysonic.web import store from supysonic.db import Folder, Artist, Album, Track from . import get_entity import uuid, string diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index afb3e24..9384e72 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -18,8 +18,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request -from supysonic.web import app, store +from flask import request, current_app as app +from supysonic.web import store from supysonic.db import ChatMessage @app.route('/rest/getChatMessages.view', methods = [ 'GET', 'POST' ]) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 07d6922..b0d4dfe 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -18,16 +18,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request, send_file, Response -import requests -import os.path -from PIL import Image -import subprocess import codecs +import mimetypes +import os.path +import requests +import subprocess + +from flask import request, send_file, Response, current_app as app +from PIL import Image from xml.etree import ElementTree -from supysonic import config, scanner -from supysonic.web import app, store +from supysonic import scanner +from supysonic.web import store from supysonic.db import Track, Album, Artist, Folder, User, ClientPrefs, now from . import get_entity @@ -70,14 +72,15 @@ def stream_media(): if format and format != 'raw' and format != src_suffix: dst_suffix = format - dst_mimetype = config.get_mime(dst_suffix) + dst_mimetype = mimetypes.guess_type('dummyname.' + dst_suffix, False)[0] or 'application/octet-stream' if format != 'raw' and (dst_suffix != src_suffix or dst_bitrate != res.bitrate): - transcoder = config.get('transcoding', 'transcoder_{}_{}'.format(src_suffix, dst_suffix)) - decoder = config.get('transcoding', 'decoder_' + src_suffix) or config.get('transcoding', 'decoder') - encoder = config.get('transcoding', 'encoder_' + dst_suffix) or config.get('transcoding', 'encoder') + config = app.config['TRANSCODING'] + transcoder = config.get('transcoder_{}_{}'.format(src_suffix, dst_suffix)) + decoder = config.get('decoder_' + src_suffix) or config.get('decoder') + encoder = config.get('encoder_' + dst_suffix) or config.get('encoder') if not transcoder and (not decoder or not encoder): - transcoder = config.get('transcoding', 'transcoder') + transcoder = config.get('transcoder') if not transcoder: message = 'No way to transcode from {} to {}'.format(src_suffix, dst_suffix) app.logger.info(message) @@ -153,7 +156,7 @@ def cover_art(): if size > im.size[0] and size > im.size[1]: return send_file(os.path.join(res.path, 'cover.jpg')) - size_path = os.path.join(config.get('webapp', 'cache_dir'), str(size)) + size_path = os.path.join(app.config['WEBAPP']['cache_dir'], str(size)) path = os.path.abspath(os.path.join(size_path, str(res.id))) if os.path.exists(path): return send_file(path) diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index 23cc5b5..fcb9935 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -18,10 +18,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request +from flask import request, current_app as app from storm.expr import Or import uuid -from supysonic.web import app, store +from supysonic.web import store from supysonic.db import Playlist, User, Track from . import get_entity diff --git a/supysonic/api/search.py b/supysonic/api/search.py index e964ac3..9eb365e 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -19,9 +19,9 @@ # along with this program. If not, see . from datetime import datetime -from flask import request +from flask import request, current_app as app from storm.info import ClassAlias -from supysonic.web import app, store +from supysonic.web import store from supysonic.db import Folder, Track, Artist, Album @app.route('/rest/search.view', methods = [ 'GET', 'POST' ]) diff --git a/supysonic/api/system.py b/supysonic/api/system.py index 228400f..fde270c 100644 --- a/supysonic/api/system.py +++ b/supysonic/api/system.py @@ -18,8 +18,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request -from supysonic.web import app +from flask import request, current_app as app @app.route('/rest/ping.view', methods = [ 'GET', 'POST' ]) def ping(): diff --git a/supysonic/api/user.py b/supysonic/api/user.py index 91a9dca..cfd07b7 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -18,8 +18,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request -from supysonic.web import app, store +from flask import request, current_app as app +from supysonic.web import store from supysonic.db import User from supysonic.managers.user import UserManager from . import decode_password diff --git a/supysonic/config.py b/supysonic/config.py index 01013e6..bfb740a 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -9,63 +9,60 @@ # # Distributed under terms of the GNU AGPLv3 license. -from ConfigParser import ConfigParser, NoOptionError, NoSectionError +from ConfigParser import SafeConfigParser -import mimetypes import os import tempfile -# Seek for standard locations -config_file = [ - 'supysonic.conf', - os.path.expanduser('~/.config/supysonic/supysonic.conf'), +class DefaultConfig(object): + DEBUG = False + SECRET_KEY = os.urandom(128) + + tempdir = os.path.join(tempfile.gettempdir(), 'supysonic') + BASE = { + 'database_uri': 'sqlite://' + os.path.join(tempdir, 'supysonic.db'), + 'scanner_extensions': None + } + WEBAPP = { + 'cache_dir': tempdir, + 'log_file': None, + 'log_level': 'WARNING', + + 'mount_webui': True, + 'mount_api': True + } + DAEMON = { + 'log_file': None, + 'log_level': 'WARNING' + } + LASTFM = { + 'api_key': None, + 'secret': None + } + TRANSCODING = {} + MIMETYPES = {} + +class IniConfig(DefaultConfig): + common_paths = [ + '/etc/supysonic', os.path.expanduser('~/.supysonic'), - '/etc/supysonic' - ] + os.path.expanduser('~/.config/supysonic/supysonic.conf'), + 'supysonic.conf' + ] -config = ConfigParser({ 'cache_dir': os.path.join(tempfile.gettempdir(), 'supysonic') }) + def __init__(self, paths): + parser = SafeConfigParser() + parser.read(paths) + for section in parser.sections(): + options = { k: v for k, v in parser.items(section) } + section = section.upper() + if hasattr(self, section): + getattr(self, section).update(options) + else: + setattr(self, section, options) -def check(): - """ - Checks the config file and mandatory fields - """ - try: - config.read(config_file) - except Exception as e: - err = 'Config file is corrupted.\n{0}'.format(e) - raise SystemExit(err) + @classmethod + def from_common_locations(cls): + return IniConfig(cls.common_paths) - try: - config.get('base', 'database_uri') - except (NoSectionError, NoOptionError): - raise SystemExit('No database URI set') - - return True - -def get(section, option): - """ - Returns a config option value from config file - - :param section: section where the option is stored - :param option: option name - :return: a config option value - :rtype: string - """ - try: - return config.get(section, option) - except (NoSectionError, NoOptionError): - return None - -def get_mime(extension): - """ - Returns mimetype of an extension based on config file - - :param extension: extension string - :return: mimetype - :rtype: string - """ - guessed_mime = mimetypes.guess_type('dummy.' + extension, False)[0] - config_mime = get('mimetypes', extension) - default_mime = 'application/octet-stream' - return guessed_mime or config_mime or default_mime diff --git a/supysonic/db.py b/supysonic/db.py index 25243e2..4acbc18 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -25,10 +25,9 @@ from storm.store import Store from storm.variables import Variable import uuid, datetime, time +import mimetypes import os.path -from supysonic import config - def now(): return datetime.datetime.now().replace(microsecond = 0) @@ -213,7 +212,7 @@ class Track(object): if prefs and prefs.format and prefs.format != self.suffix(): info['transcodedSuffix'] = prefs.format - info['transcodedContentType'] = config.get_mime(prefs.format) + info['transcodedContentType'] = mimetypes.guess_type('dummyname.' + prefs.format, False)[0] or 'application/octet-stream' return info diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index 0dae398..6c7a208 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -9,8 +9,8 @@ # # Distributed under terms of the GNU AGPLv3 license. -from flask import session, request, redirect, url_for -from supysonic.web import app, store +from flask import session, request, redirect, url_for, current_app as app +from supysonic.web import store from supysonic.db import Artist, Album, Track from supysonic.managers.user import UserManager from functools import wraps diff --git a/supysonic/frontend/folder.py b/supysonic/frontend/folder.py index 80e0f0b..e375862 100644 --- a/supysonic/frontend/folder.py +++ b/supysonic/frontend/folder.py @@ -18,11 +18,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request, flash, render_template, redirect, url_for +from flask import request, flash, render_template, redirect, url_for, current_app as app import os.path import uuid -from supysonic.web import app, store +from supysonic.web import store from supysonic.db import Folder from supysonic.scanner import Scanner from supysonic.managers.user import UserManager @@ -84,7 +84,10 @@ def del_folder(id): @app.route('/folder/scan/') @admin_only def scan_folder(id = None): - scanner = Scanner(store) + extensions = app.config['BASE']['scanner_extensions'] + if extensions: + extensions = extensions.split(' ') + scanner = Scanner(store, extensions = extensions) if id is None: for folder in store.find(Folder, Folder.root == True): scanner.scan(folder) diff --git a/supysonic/frontend/playlist.py b/supysonic/frontend/playlist.py index 98c02b8..9f81ddd 100644 --- a/supysonic/frontend/playlist.py +++ b/supysonic/frontend/playlist.py @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from flask import request, flash, render_template, redirect, url_for +from flask import request, flash, render_template, redirect, url_for, current_app as app import uuid -from supysonic.web import app, store +from supysonic.web import store from supysonic.db import Playlist from supysonic.managers.user import UserManager diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index db9484e..6ebb0fc 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -18,15 +18,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import uuid, csv - -from flask import request, session, flash, render_template, redirect, url_for, make_response +from flask import request, session, flash, render_template, redirect, url_for, current_app as app from functools import wraps -from supysonic.web import app, store +from supysonic.web import store from supysonic.managers.user import UserManager from supysonic.db import User, ClientPrefs -from supysonic import config from supysonic.lastfm import LastFm from . import admin_only @@ -67,7 +64,7 @@ def user_index(): @me_or_uuid def user_profile(uid, user): prefs = store.find(ClientPrefs, ClientPrefs.user_id == user.id) - return render_template('profile.html', user = user, has_lastfm = config.get('lastfm', 'api_key') != None, clients = prefs) + return render_template('profile.html', user = user, has_lastfm = app.config['LASTFM']['api_key'] != None, clients = prefs) @app.route('/user/', methods = [ 'POST' ]) @me_or_uuid @@ -251,7 +248,7 @@ def lastfm_reg(uid, user): flash('Missing LastFM auth token') return redirect(url_for('user_profile', uid = uid)) - lfm = LastFm(user, app.logger) + lfm = LastFm(app.config['LASTFM'], user, app.logger) status, error = lfm.link_account(token) store.commit() flash(error if not status else 'Successfully linked LastFM account') @@ -261,7 +258,7 @@ def lastfm_reg(uid, user): @app.route('/user//lastfm/unlink') @me_or_uuid def lastfm_unreg(uid, user): - lfm = LastFm(user, app.logger) + lfm = LastFm(app.config['LASTFM'], user, app.logger) lfm.unlink_account() store.commit() flash('Unlinked LastFM account') diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index 801b07c..d382854 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -3,7 +3,7 @@ # This file is part of Supysonic. # # Supysonic is a Python implementation of the Subsonic server API. -# Copyright (C) 2013 Alban 'spl0k' Féron +# Copyright (C) 2013-2017 Alban 'spl0k' Féron # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -19,13 +19,12 @@ # along with this program. If not, see . import requests, hashlib -from supysonic import config class LastFm: - def __init__(self, user, logger): + def __init__(self, config, user, logger): + self.__api_key = config['api_key'] + self.__api_secret = config['secret'] self.__user = user - self.__api_key = config.get('lastfm', 'api_key') - self.__api_secret = config.get('lastfm', 'secret') self.__enabled = self.__api_key is not None and self.__api_secret is not None self.__logger = logger diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 1cb0567..789bdbc 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -19,13 +19,13 @@ # along with this program. If not, see . import os, os.path -import time +import mimetypes import mutagen +import time from storm.expr import ComparableExpr, compile, Like from storm.exceptions import NotSupportedError -from supysonic import config from supysonic.db import Folder, Artist, Album, Track, User from supysonic.db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from supysonic.db import RatingFolder, RatingTrack @@ -52,7 +52,10 @@ def compile_concat(compile, concat, state): return statement % (left, right) class Scanner: - def __init__(self, store, force = False): + def __init__(self, store, force = False, extensions = None): + if extensions is not None and not isinstance(extensions, list): + raise TypeError('Invalid extensions type') + self.__store = store self.__force = force @@ -63,8 +66,7 @@ class Scanner: self.__deleted_albums = 0 self.__deleted_tracks = 0 - extensions = config.get('base', 'scanner_extensions') - self.__extensions = map(str.lower, extensions.split()) if extensions else None + self.__extensions = extensions self.__folders_to_check = set() self.__artists_to_check = set() @@ -172,7 +174,7 @@ class Scanner: tr.duration = int(tag.info.length) tr.bitrate = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) / 1000 - tr.content_type = config.get_mime(os.path.splitext(path)[1][1:]) + tr.content_type = mimetypes.guess_type(path, False)[0] or 'application/octet-stream' tr.last_modification = os.path.getmtime(path) tralbum = self.__find_album(albumartist, album) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 1e762df..2b5fda7 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -26,7 +26,7 @@ from logging.handlers import TimedRotatingFileHandler from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler -from supysonic import config, db +from supysonic import db from supysonic.scanner import Scanner OP_SCAN = 1 @@ -35,8 +35,7 @@ OP_MOVE = 4 FLAG_CREATE = 8 class SupysonicWatcherEventHandler(PatternMatchingEventHandler): - def __init__(self, queue, logger): - extensions = config.get('base', 'scanner_extensions') + def __init__(self, extensions, queue, logger): patterns = map(lambda e: "*." + e.lower(), extensions.split()) if extensions else None super(SupysonicWatcherEventHandler, self).__init__(patterns = patterns, ignore_directories = True) @@ -109,10 +108,11 @@ class Event(object): return self.__src class ScannerProcessingQueue(Thread): - def __init__(self, logger): + def __init__(self, database_uri, logger): super(ScannerProcessingQueue, self).__init__() self.__logger = logger + self.__database_uri = database_uri self.__cond = Condition() self.__timer = None self.__queue = {} @@ -135,7 +135,7 @@ class ScannerProcessingQueue(Thread): continue self.__logger.debug("Instantiating scanner") - store = db.get_store(config.get('base', 'database_uri')) + store = db.get_store(self.__database_uri) scanner = Scanner(store) item = self.__next_item() @@ -202,18 +202,18 @@ class ScannerProcessingQueue(Thread): return None class SupysonicWatcher(object): - def run(self): - if not config.check(): - return + def __init__(self, config): + self.__config = config + def run(self): logger = logging.getLogger(__name__) - if config.get('daemon', 'log_file'): - log_handler = TimedRotatingFileHandler(config.get('daemon', 'log_file'), when = 'midnight') + if self.__config.DAEMON['log_file']: + log_handler = TimedRotatingFileHandler(self.__config.DAEMON['log_file'], when = 'midnight') else: log_handler = logging.NullHandler() log_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(log_handler) - if config.get('daemon', 'log_level'): + if self.__config.DAEMON['log_level']: mapping = { 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, @@ -221,9 +221,9 @@ class SupysonicWatcher(object): 'ERROR': logging.ERROR, 'CRTICAL': logging.CRITICAL } - logger.setLevel(mapping.get(config.get('daemon', 'log_level').upper(), logging.NOTSET)) + logger.setLevel(mapping.get(self.__config.DAEMON['log_level'].upper(), logging.NOTSET)) - store = db.get_store(config.get('base', 'database_uri')) + store = db.get_store(self.__config.BASE['database_uri']) folders = store.find(db.Folder, db.Folder.root == True) if not folders.count(): @@ -231,8 +231,8 @@ class SupysonicWatcher(object): store.close() return - queue = ScannerProcessingQueue(logger) - handler = SupysonicWatcherEventHandler(queue, logger) + queue = ScannerProcessingQueue(self.__config.BASE['database_uri'], logger) + handler = SupysonicWatcherEventHandler(self.__config.BASE['scanner_extensions'], queue, logger) observer = Observer() for folder in folders: diff --git a/supysonic/web.py b/supysonic/web.py index 862b484..7890037 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -9,17 +9,19 @@ # # Distributed under terms of the GNU AGPLv3 license. -from flask import Flask, g +import mimetypes + +from flask import Flask, g, current_app from os import makedirs, path from werkzeug.local import LocalProxy -from supysonic import config +from supysonic.config import IniConfig from supysonic.db import get_store # Supysonic database open def get_db(): if not hasattr(g, 'database'): - g.database = get_store(config.get('base', 'database_uri')) + g.database = get_store(current_app.config['BASE']['database_uri']) return g.database # Supysonic database close @@ -29,36 +31,28 @@ def close_db(error): store = LocalProxy(get_db) -def create_application(): +def create_application(config = None): global app - # Check config for mandatory fields - config.check() - - # Test for the cache directory - if not path.exists(config.get('webapp', 'cache_dir')): - makedirs(config.get('webapp', 'cache_dir')) - # Flask! app = Flask(__name__) + app.config.from_object('supysonic.config.DefaultConfig') - # Set a secret key for sessions - secret_key = config.get('base', 'secret_key') - # If secret key is not defined in config, set develop key - if secret_key is None: - app.secret_key = 'd3v3l0p' - else: - app.secret_key = secret_key + if not config: + config = IniConfig.from_common_locations() + app.config.from_object(config) # Close database connection on teardown app.teardown_appcontext(close_db) # Set loglevel - if config.get('webapp', 'log_file'): + logfile = app.config['WEBAPP']['log_file'] + if logfile: import logging from logging.handlers import TimedRotatingFileHandler - handler = TimedRotatingFileHandler(config.get('webapp', 'log_file'), when = 'midnight') - if config.get('webapp', 'log_level'): + handler = TimedRotatingFileHandler(logfile, when = 'midnight') + loglevel = app.config['WEBAPP']['log_level'] + if loglevel: mapping = { 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, @@ -66,11 +60,26 @@ def create_application(): 'ERROR': logging.ERROR, 'CRTICAL': logging.CRITICAL } - handler.setLevel(mapping.get(config.get('webapp', 'log_level').upper(), logging.NOTSET)) + handler.setLevel(mapping.get(loglevel.upper(), logging.NOTSET)) app.logger.addHandler(handler) + # Insert unknown mimetypes + for k, v in app.config['MIMETYPES'].iteritems(): + extension = '.' + k.lower() + if extension not in mimetypes.types_map: + mimetypes.add_type(v, extension, False) + + # Test for the cache directory + cache_path = app.config['WEBAPP']['cache_dir'] + if not path.exists(cache_path): + makedirs(cache_path) + # Import app sections - from supysonic import frontend - from supysonic import api + with app.app_context(): + if app.config['WEBAPP']['mount_webui']: + from supysonic import frontend + if app.config['WEBAPP']['mount_api']: + from supysonic import api return app + diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index 9a45b19..cc3d540 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -21,7 +21,7 @@ NS = 'http://subsonic.org/restapi' NSMAP = { 'sub': NS } class ApiTestBase(TestBase): - __module_to_test__ = 'supysonic.api' + __with_api__ = True def setUp(self): super(ApiTestBase, self).setUp() diff --git a/tests/api/test_api_setup.py b/tests/api/test_api_setup.py index 10354cc..04bfcca 100644 --- a/tests/api/test_api_setup.py +++ b/tests/api/test_api_setup.py @@ -19,7 +19,7 @@ from xml.etree import ElementTree from ..testbase import TestBase class ApiSetupTestCase(TestBase): - __module_to_test__ = 'supysonic.api' + __with_api__ = True def __basic_auth_get(self, username, password): hashed = base64.b64encode('{}:{}'.format(username, password)) diff --git a/tests/api/test_response_helper.py b/tests/api/test_response_helper.py index 8f7048f..d445b44 100644 --- a/tests/api/test_response_helper.py +++ b/tests/api/test_response_helper.py @@ -9,24 +9,20 @@ # # Distributed under terms of the GNU AGPLv3 license. -import unittest, sys +import unittest import simplejson from xml.etree import ElementTree -from ..appmock import AppMock +from ..testbase import TestBase -class ResponseHelperBaseCase(unittest.TestCase): +class ResponseHelperBaseCase(TestBase): def setUp(self): - sys.modules[u'supysonic.web'] = AppMock(with_store = False) + super(ResponseHelperBaseCase, self).setUp() + from supysonic.api import ResponseHelper self.helper = ResponseHelper - def tearDown(self): - to_unload = [ m for m in sys.modules if m.startswith('supysonic') ] - for m in to_unload: - del sys.modules[m] - class ResponseHelperJsonTestCase(ResponseHelperBaseCase): def serialize_and_deserialize(self, d, error = False): if not isinstance(d, dict): diff --git a/tests/appmock.py b/tests/appmock.py deleted file mode 100644 index 6567b20..0000000 --- a/tests/appmock.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -# vim:fenc=utf-8 -# -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2017 Alban 'spl0k' Féron -# -# Distributed under terms of the GNU AGPLv3 license. - -import io -from flask import Flask -from supysonic.db import get_store - -class AppMock(object): - def __init__(self, with_store = True): - self.app = Flask(__name__, template_folder = '../supysonic/templates') - self.app.testing = True - self.app.secret_key = 'Testing secret' - - if with_store: - self.store = get_store('sqlite:') - with io.open('schema/sqlite.sql', 'r') as sql: - schema = sql.read() - for statement in schema.split(';'): - self.store.execute(statement) - else: - self.store = None - diff --git a/tests/frontend/frontendtestbase.py b/tests/frontend/frontendtestbase.py index ac379cc..e290836 100644 --- a/tests/frontend/frontendtestbase.py +++ b/tests/frontend/frontendtestbase.py @@ -11,6 +11,8 @@ from ..testbase import TestBase class FrontendTestBase(TestBase): + __with_webui__ = True + def _login(self, username, password): return self.client.post('/user/login', data = { 'user': username, 'password': password }, follow_redirects = True) diff --git a/tests/frontend/test_folder.py b/tests/frontend/test_folder.py index 65ed866..30c9251 100644 --- a/tests/frontend/test_folder.py +++ b/tests/frontend/test_folder.py @@ -16,8 +16,6 @@ from supysonic.db import Folder from .frontendtestbase import FrontendTestBase class FolderTestCase(FrontendTestBase): - __module_to_test__ = 'supysonic.frontend' - def test_index(self): self._login('bob', 'B0b') rv = self.client.get('/folder', follow_redirects = True) diff --git a/tests/frontend/test_login.py b/tests/frontend/test_login.py index 60b7ae6..b908abe 100644 --- a/tests/frontend/test_login.py +++ b/tests/frontend/test_login.py @@ -17,8 +17,6 @@ from supysonic.db import User from .frontendtestbase import FrontendTestBase class LoginTestCase(FrontendTestBase): - __module_to_test__ = 'supysonic.frontend' - def test_unauthorized_request(self): # Unauthorized request rv = self.client.get('/', follow_redirects=True) diff --git a/tests/frontend/test_playlist.py b/tests/frontend/test_playlist.py index f0de738..80e6bed 100644 --- a/tests/frontend/test_playlist.py +++ b/tests/frontend/test_playlist.py @@ -16,8 +16,6 @@ from supysonic.db import Folder, Artist, Album, Track, Playlist, User from .frontendtestbase import FrontendTestBase class PlaylistTestCase(FrontendTestBase): - __module_to_test__ = 'supysonic.frontend' - def setUp(self): super(PlaylistTestCase, self).setUp() diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index 4cd3447..cb695d4 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -16,8 +16,6 @@ from supysonic.db import User, ClientPrefs from .frontendtestbase import FrontendTestBase class UserTestCase(FrontendTestBase): - __module_to_test__ = 'supysonic.frontend' - def setUp(self): super(UserTestCase, self).setUp() diff --git a/tests/testbase.py b/tests/testbase.py index 35ec190..ec4ad6f 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -8,29 +8,57 @@ # # Distributed under terms of the GNU AGPLv3 license. -import importlib -import unittest +import io import sys +import unittest +from supysonic.config import DefaultConfig from supysonic.managers.user import UserManager +from supysonic.web import create_application, store -from .appmock import AppMock +class TestConfig(DefaultConfig): + TESTING = True + LOGGER_HANDLER_POLICY = 'never' + BASE = { + 'database_uri': 'sqlite:', + 'scanner_extensions': None + } + MIMETYPES = { + 'mp3': 'audio/mpeg', + 'weirdextension': 'application/octet-stream' + } + + def __init__(self, with_webui, with_api): + super(TestConfig, self).__init__ + + self.WEBAPP.update({ + 'mount_webui': with_webui, + 'mount_api': with_api + }) class TestBase(unittest.TestCase): - def setUp(self): - app_mock = AppMock() - self.app = app_mock.app - self.store = app_mock.store - self.client = self.app.test_client() + __with_webui__ = False + __with_api__ = False - sys.modules['supysonic.web'] = app_mock - importlib.import_module(self.__module_to_test__) + def setUp(self): + app = create_application(TestConfig(self.__with_webui__, self.__with_api__)) + self.__ctx = app.app_context() + self.__ctx.push() + + self.store = store + with io.open('schema/sqlite.sql', 'r') as sql: + schema = sql.read() + for statement in schema.split(';'): + self.store.execute(statement) + + self.client = app.test_client() UserManager.add(self.store, 'alice', 'Alic3', 'test@example.com', True) UserManager.add(self.store, 'bob', 'B0b', 'bob@example.com', False) def tearDown(self): - self.store.close() + self.__ctx.pop() + to_unload = [ m for m in sys.modules if m.startswith('supysonic') ] for m in to_unload: del sys.modules[m]