From 588ecc4c023ef2457e5f96dd10e843b7e63eafa2 Mon Sep 17 00:00:00 2001 From: n9k Date: Mon, 13 Jun 2022 03:46:02 +0000 Subject: [PATCH] Control socket: progress --- anonstream/control/server.py | 272 ++++++++++++++++++++++++----------- anonstream/stream.py | 4 + 2 files changed, 194 insertions(+), 82 deletions(-) diff --git a/anonstream/control/server.py b/anonstream/control/server.py index 20b9082..903433b 100644 --- a/anonstream/control/server.py +++ b/anonstream/control/server.py @@ -1,125 +1,233 @@ import asyncio import json -from anonstream.stream import get_stream_title +from anonstream.stream import get_stream_title, set_stream_title + +class UnknownMethod(Exception): + pass + +class UnknownCommand(Exception): + pass + +class UnknownArgument(Exception): + pass + +class BadArgument(Exception): + pass + +class Incomplete(Exception): + pass + +class Garbage(Exception): + pass + +class Failed(Exception): + pass + +class Exit(Exception): + pass def start_control_server_at(address): - return asyncio.start_unix_server( - handle_control_client, - address, - ) + return asyncio.start_unix_server(serve_control_client, address) -async def handle_control_client(reader, writer): +async def serve_control_client(reader, writer): while line := await reader.readline(): try: request = line.decode('utf-8') except UnicodeDecodeError as e: - response = f'error: {e}' + normal, response = None, str(e) else: - method, args, normal, response = await parse(request) - if method is None: - pass - elif normal is not None: - writer.write(f'{normal}\n'.encode()) - elif response is not None: - writer.write(f'error: '.encode()) - if response is not None: - writer.write(response.encode()) - await writer.drain() - else: - writer.close() - break + try: + normal, response = await parse_request(request) + except Exit: + writer.close() + break -async def parse(request): + if normal is not None: + normal_method, normal_options = normal + if normal_method is not None: + writer.write(normal_method.encode()) + for arg in normal_options: + writer.write(b' ') + writer.write(arg.encode()) + writer.write(b'\n') + elif response: + writer.write(b'error: ') + + writer.write(response.encode()) + await writer.drain() + +async def parse_request(request): try: - method, *args = request.split() + method, *options = request.split() except ValueError: - method, args, normal, response = None, [], None, '' + normal, response = (None, []), '' else: - match method: - case 'help': - normal_args, response = await parse_help(args) - case 'exit': - normal_args, response = await parse_exit(args) - case 'title': - normal_args, response = await parse_title(args) - case _: - normal_args = None - response = f"method {method!r} is unknown, try 'help'\n" - if normal_args is None: + try: + normal, response = await parse(method, options) + except UnknownMethod as e: + unknown_method, *_ = e.args normal = None - if response is None: - response = f"command {args[0]!r} is unknown, try {f'{method} help'!r}\n" - elif len(normal_args) == 0: - normal = method - else: - normal = f'{method} {" ".join(normal_args)}' or method - return method, args, normal, response + response = f"method {unknown_method!r} is unknown, try 'help'\n" + except UnknownCommand as e: + method, unknown_command, *_ = e.args + normal = None + response = f"command {unknown_command!r} is unknown, try {f'{method} help'!r}\n" + except BadArgument as e: + reason, *_ = e.args + normal = None + response = f"{reason}, try {f'{method} help'!r}\n" + except Incomplete as e: + method, *_ = e.args + normal = None + response = f"command is incomplete, try {f'{method} help'!r}\n" + except Garbage as e: + garbage, *_ = e.args + normal = None + response = f"command has trailing garbage {garbage!r}, try {f'{method} help'!r}\n" + except Failed as e: + reason, *_ = e.args + normal = None + response = reason + '\n' + return normal, response -async def parse_help(args): +async def parse(method, options): + try: + command, *args = options + except ValueError: + command, args = None, [] + try: + functions = METHOD_COMMAND_FUNCTIONS[method] + except KeyError: + raise UnknownMethod(method) + else: + normal_method = method + try: + fn = functions[command] + except KeyError: + raise UnknownCommand(method, command) + else: + try: + normal_options, response = await fn(args) + except Incomplete as e: + raise Incomplete(method) from e + normal = (normal_method, normal_options) + return normal, response + +async def command_help(args): match args: case []: - normal_args = [] + normal_options = [] response = ( - 'Usage: METHOD {COMMAND | help}\n' + 'Usage: METHOD [COMMAND | help]\n' 'Examples:\n' ' help.......................show this help message\n' ' exit.......................close the connection\n' - ' title [show [CODEC]].......show the stream title\n' + ' title [show]...............show the stream title\n' ' title set TITLE............set the stream title\n' ' user [show]................show a list of users\n' ' user set USER ATTR VALUE...set an attribute of a user\n' ) - case ['help']: - normal_args = ['help'] + case [*garbage]: + raise Garbage(garbage) + return normal_options, response + +async def command_help_help(args): + match args: + case []: + normal_options = ['help'] response = ( 'Usage: help\n' 'show usage syntax and examples\n' ) - case _: - normal_args = None - response = None - return normal_args, response + case [*garbage]: + raise Garbage(garbage) + return normal_options, response -async def parse_exit(args): +async def command_exit(args): match args: case []: - normal_args = [] - response = None - case ['help']: - normal_args = ['help'] + raise Exit + case [*garbage]: + raise Garbage(garbage) + +async def command_exit_help(args): + match args: + case []: + normal_options = ['help'] response = ( - 'Usage: {exit | quit}\n' + 'Usage: exit\n' 'close the connection\n' ) - case _: - normal_args = None - response = None - return normal_args, response + case [*garbage]: + raise Garbage(garbage) + return normal_options, response -async def parse_title(args): +async def command_title_help(args): match args: - case [] | ['show'] | ['show', 'json']: - normal_args = ['show', 'json'] - response = json.dumps(await get_stream_title()) + '\n' - case ['show', 'utf-8']: - normal_args = ['show'] - response = await get_stream_title() + '\n' - case ['show', arg, *_]: - normal_args = None - response = f"option {arg!r} is unknown, try 'title help'\n" - case ['help']: - normal_args = ['help'] + case []: + normal_options = ['help'] response = ( - 'Usage: title {show [CODEC] | set TITLE}\n' + 'Usage: title [show | set TITLE]\n' 'Commands:\n' - ' title show [CODEC].....show the stream title\n' - ' title set TITLE........set the stream title to TITLE collapsing whitespace\n' + ' title [show].......show the stream title\n' + ' title set TITLE....set the stream title to TITLE\n' 'Arguments:\n' - ' CODEC..................=[utf-8 | json]\n' - ' TITLE..................a UTF-8-encoded string\n' + ' TITLE..............a json-encoded string, whitespace must be \\uXXXX-escaped\n' ) - case _: - normal_args = None - response = None - return normal_args, response + case [*garbage]: + raise Garbage(garbage) + return normal_options, response + +async def command_title_show(args): + match args: + case []: + normal_options = ['show'] + response = json.dumps(await get_stream_title()) + '\n' + case [*garbage]: + raise Garbage(garbage) + return normal_options, response + +async def command_title_set(args): + match args: + case [title_json]: + try: + title = json.loads(title_json) + except json.JSONDecodeError as e: + raise BadArgument('could not decode json') + else: + if not isinstance(title, str): + raise BadArgument('could not decode json as string') + else: + try: + await set_stream_title(title) + except OSError as e: + raise Failed(str(e)) from e + normal_options = ['set', json.dumps(title).replace(' ', r'\u0020')] + response = '' + case []: + raise Incomplete + case [_, *garbage]: + raise Garbage(garbage) + return normal_options, response + +METHOD_HELP = 'help' +METHOD_EXIT = 'exit' +METHOD_TITLE = 'title' + +METHOD_COMMAND_FUNCTIONS = { + METHOD_HELP: { + None: command_help, + 'help': command_help_help, + }, + METHOD_EXIT: { + None: command_exit, + 'help': command_exit_help, + }, + METHOD_TITLE: { + None: command_title_show, + 'help': command_title_help, + 'show': command_title_show, + 'set': command_title_set, + }, +} diff --git a/anonstream/stream.py b/anonstream/stream.py index bdb7b79..5f88687 100644 --- a/anonstream/stream.py +++ b/anonstream/stream.py @@ -24,6 +24,10 @@ async def get_stream_title(): title = '' return title +async def set_stream_title(title): + async with aiofiles.open(CONFIG['STREAM_TITLE'], 'w') as fp: + await fp.write(title) + def get_stream_uptime(rounded=True): try: playlist, mtime = get_playlist()