diff --git a/setup.py b/setup.py index 04a7f0e..2bec624 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ from setuptools import setup from setuptools import find_packages reqs = [ + "click", "flask>=0.11", "pony>=0.7.6", "Pillow", @@ -37,6 +38,7 @@ setup( "console_scripts": [ "supysonic-cli=supysonic.cli:main", "supysonic-daemon=supysonic.daemon:main", + "supysonic-server=supysonic.server:main" ] }, zip_safe=False, diff --git a/supysonic/server/__init__.py b/supysonic/server/__init__.py new file mode 100644 index 0000000..cafa3f4 --- /dev/null +++ b/supysonic/server/__init__.py @@ -0,0 +1,127 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import importlib +import os +import os.path + +from click import command, option, Option +from click.exceptions import UsageError, ClickException +from click.types import Choice + +from ..web import create_application + +_servers = [ + e.name[:-3] + for e in os.scandir(os.path.dirname(__file__)) + if not e.name.startswith("_") and e.name.endswith(".py") +] + + +class MutuallyExclusiveOption(Option): + def __init__(self, *args, **kwargs): + self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", [])) + help = kwargs.get("help", "") + if self.mutually_exclusive: + ex_str = ", ".join(self.mutually_exclusive) + kwargs[ + "help" + ] = "{} NOTE: This argument is mutually exclusive with arguments: [{}].".format( + help, ex_str + ) + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + if self.mutually_exclusive.intersection(opts) and self.name in opts: + raise UsageError( + "Illegal usage: `{}` is mutually exclusive with arguments `{}`.".format( + self.name, ", ".join(self.mutually_exclusive) + ) + ) + + return super().handle_parse_result(ctx, opts, args) + + +def get_server(name): + return importlib.import_module("." + name, __package__).server + + +def find_first_available_server(): + for module in _servers: + try: + return get_server(module) + except ImportError: + pass + + return None + + +@command() +@option( + "-S", + "--server", + type=Choice(_servers), + help="Specify which WSGI server to use. Pick the first available if none is set", +) +@option( + "-h", + "--host", + default="0.0.0.0", + show_default=True, + help="Hostname or IP address on which to listen", + cls=MutuallyExclusiveOption, + mutually_exclusive=("socket",), +) +@option( + "-p", + "--port", + default=5722, + show_default=True, + help="TCP port on which to listen", + cls=MutuallyExclusiveOption, + mutually_exclusive=("socket",), +) +@option( + "-s", + "--socket", + help="Unix socket on which to bind to, Can't be used with --host and --port", + cls=MutuallyExclusiveOption, + mutually_exclusive=("host", "port"), +) +@option( + "--processes", + type=int, + help="Number of processes to spawn. May not be supported by all servers", +) +@option( + "--threads", + type=int, + help="Number of threads used to process application logic. May not be supported by all servers", +) +def main(server, host, port, socket, processes, threads): + if server is None: + server = find_first_available_server() + if server is None: + raise ClickException( + "Couldn't load any server, please install one of {}".format(_servers) + ) + else: + try: + server = get_server(server) + except ImportError: + raise ClickException( + "Couldn't load {}, please install it first".format(server) + ) + + if socket is not None: + host = None + port = None + + app = create_application() + server( + app, host=host, port=port, socket=socket, processes=processes, threads=threads + ).run() diff --git a/supysonic/server/__main__.py b/supysonic/server/__main__.py new file mode 100644 index 0000000..93153f6 --- /dev/null +++ b/supysonic/server/__main__.py @@ -0,0 +1,11 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from . import main + +if __name__ == "__main__": + main() diff --git a/supysonic/server/_base.py b/supysonic/server/_base.py new file mode 100644 index 0000000..a47791c --- /dev/null +++ b/supysonic/server/_base.py @@ -0,0 +1,32 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from abc import ABCMeta, abstractmethod + + +class BaseServer(metaclass=ABCMeta): + def __init__( + self, app, *, host=None, port=None, socket=None, processes=None, threads=None + ): + self._app = app + + self._host = host + self._port = port + self._socket = socket + self._processes = processes + self._threads = threads + + @abstractmethod + def _build_kwargs(self): + ... + + @abstractmethod + def _run(self, **kwargs): + ... + + def run(self): + self._run(**self._build_kwargs()) diff --git a/supysonic/server/gevent.py b/supysonic/server/gevent.py new file mode 100644 index 0000000..ba94f72 --- /dev/null +++ b/supysonic/server/gevent.py @@ -0,0 +1,39 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import os +import os.path + +from gevent import socket +from gevent.pywsgi import WSGIServer + +from ._base import BaseServer + + +class GeventServer(BaseServer): + def _build_kwargs(self): + rv = {"application": self._app} + + if self._socket is not None: + if os.path.exists(self._socket): + os.remove(self._socket) + + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(self._socket) + listener.listen() + + rv["listener"] = listener + else: + rv["listener"] = (self._host, self._port) + + return rv + + def _run(self, **kwargs): + return WSGIServer(**kwargs).serve_forever() + + +server = GeventServer diff --git a/supysonic/server/gunicorn.py b/supysonic/server/gunicorn.py new file mode 100644 index 0000000..3e6613d --- /dev/null +++ b/supysonic/server/gunicorn.py @@ -0,0 +1,53 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from gunicorn.app.base import BaseApplication + +from ._base import BaseServer + + +class GunicornApp(BaseApplication): + def __init__(self, app, **config): + self.__app = app + self.__config = config + + super().__init__() + + def load(self): + return self.__app + + def load_config(self): + socket = self.__config["socket"] + host = self.__config["host"] + port = self.__config["port"] + processes = self.__config["processes"] + threads = self.__config["threads"] + + if socket is not None: + self.cfg.set("bind", "unix:{}".format(socket)) + else: + self.cfg.set("bind", "{}:{}".format(host, port)) + + if processes is not None: + self.cfg.set("workers", processes) + if threads is not None: + self.cfg.set("threads", threads) + + +class GunicornServer(BaseServer): + def __init__(self, app, **kwargs): + super().__init__(app, **kwargs) + self.__server = GunicornApp(app, **kwargs) + + def _build_kwargs(self): + return {} + + def _run(self, **kwargs): + return self.__server.run() + + +server = GunicornServer diff --git a/supysonic/server/waitress.py b/supysonic/server/waitress.py new file mode 100644 index 0000000..99ded0c --- /dev/null +++ b/supysonic/server/waitress.py @@ -0,0 +1,32 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2021 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +from waitress import serve + +from ._base import BaseServer + + +class WaitressServer(BaseServer): + def _build_kwargs(self): + rv = {"app": self._app} + + if self._host is not None: + rv["host"] = self._host + if self._port is not None: + rv["port"] = self._port + if self._socket is not None: + rv["unix_socket"] = self._socket + if self._threads is not None: + rv["threads"] = self._threads + + return rv + + def _run(self, **kwargs): + return serve(**kwargs) + + +server = WaitressServer diff --git a/tests/issue221.py b/tests/issue221.py old mode 100755 new mode 100644