diff --git a/groove/cli.py b/groove/cli.py index 4ebf2a6..f874bad 100644 --- a/groove/cli.py +++ b/groove/cli.py @@ -10,7 +10,7 @@ from rich import print import rich.table from groove import webserver -from groove.shell import start_shell +from groove.shell import interactive_shell from groove.playlist import Playlist from groove import db from groove.db.manager import database_manager @@ -156,7 +156,7 @@ def scan( @app.command() def shell(): initialize() - start_shell() + interactive_shell.start() @app.command() diff --git a/groove/db/manager.py b/groove/db/manager.py index 0a50029..f1e4321 100644 --- a/groove/db/manager.py +++ b/groove/db/manager.py @@ -1,11 +1,30 @@ import os +from prompt_toolkit.completion import Completion, FuzzyCompleter from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from . import metadata +class FuzzyTableCompleter(FuzzyCompleter): + + def __init__(self, table, column, formatter, session): + self._table = table + self._column = column + self._formatter = formatter + self._session = session + + def get_completions(self, document, complete_event): + line = document.current_line_before_cursor + query = self._session.query(self._table).filter(self._column.ilike(f"%{line}%")) + for row in query.all(): + yield Completion( + self._formatter(row), + start_position=-len(line) + ) + + class DatabaseManager: """ A context manager for working with sqllite database. @@ -18,7 +37,7 @@ class DatabaseManager: @property def engine(self): if not self._engine: - self._engine = create_engine(f"sqlite:///{os.environ.get('DATABASE_PATH')}", future=True) + self._engine = create_engine(f"sqlite:///{os.environ.get('DATABASE_PATH')}?check_same_thread=False", future=True) return self._engine @property @@ -31,6 +50,9 @@ class DatabaseManager: def import_from_filesystem(self): pass + def fuzzy_table_completer(self, table, column, formatter): + return FuzzyTableCompleter(table, column, formatter, session=self.session) + def __enter__(self): metadata.create_all(bind=self.engine) return self diff --git a/groove/handlers.py b/groove/handlers.py deleted file mode 100644 index e2093b0..0000000 --- a/groove/handlers.py +++ /dev/null @@ -1,105 +0,0 @@ -from prompt_toolkit import prompt -from prompt_toolkit.completion import Completion, FuzzyCompleter -from slugify import slugify -from sqlalchemy import func -from rich import print - -from groove import db -from groove.playlist import Playlist - - -class FuzzyTableCompleter(FuzzyCompleter): - - def __init__(self, table, column, formatter, session): - super(FuzzyTableCompleter).__init__() - self._table = table - self._column = column - self._formatter = formatter - self._session = session - - def get_completions(self, document, complete_event): - word = document.get_word_before_cursor() - query = self._session.query(self._table).filter(self._column.ilike(f"%{word}%")) - for row in query.all(): - yield Completion( - self._formatter(row), - start_position=-len(word) - ) - - -class Command: - def __init__(self, processor): - self._processor = processor - - def handle(self, *parts): - raise NotImplementedError() - - -class help(Command): - """Display the help documentation.""" - def handle(self, *parts): - print("Available commands:") - for handler in Command.__subclasses__(): - print(f"{handler.__name__}: {handler.__doc__}") - - -class add(Command): - """Add a track to the current playlist.""" - def handle(self, *parts): - if not self._processor.playlist: - print("Please select a playlist first, using the 'playlist' command.") - return - text = prompt( - 'Add which track? > ', - completer=FuzzyTableCompleter(db.track, db.track.c.relpath, self._track_to_string, self._processor.session), - complete_in_thread=True, complete_while_typing=True - ) - return text - - def _track_to_string(row): - return f"{row.artist} - {row.title}" - - -class list(Command): - """Display the current playlist.""" - def handle(self, *parts): - if not self._processor.playlist: - print("Please select a playlist first, using the 'playlist' command.") - return - print(self._processor.playlist.as_dict) - - -class stats(Command): - """Display database statistics.""" - def handle(self, *parts): - sess = self._processor.session - playlists = sess.query(func.count(db.playlist.c.id)).scalar() - entries = sess.query(func.count(db.entry.c.track)).scalar() - tracks = sess.query(func.count(db.track.c.relpath)).scalar() - print(f"Database contains {playlists} playlists with a total of {entries} entries, from {tracks} known tracks.") - - -class quit(Command): - """Exit the interactive shell.""" - def handle(self, *parts): - raise SystemExit() - - -class playlist(Command): - """Create or load a playlist.""" - def handle(self, *parts): - name = ' '.join(parts) - slug = slugify(name) - self._processor.playlist = Playlist( - slug=slug, - name=name, - session=self._processor.session, - create_if_not_exists=True - ) - self._processor.prompt = slug - print(f"Loaded playlist with slug {self._processor.playlist.record.slug}.") - - -def load(processor): - for handler in Command.__subclasses__(): - yield handler.__name__, handler(processor) diff --git a/groove/playlist.py b/groove/playlist.py index 1f5f60b..a3e03a3 100644 --- a/groove/playlist.py +++ b/groove/playlist.py @@ -4,7 +4,9 @@ from sqlalchemy.orm.session import Session from sqlalchemy.engine.row import Row from sqlalchemy.exc import NoResultFound, MultipleResultsFound from typing import Union, List + import logging +import os class Playlist: @@ -32,6 +34,14 @@ class Playlist: """ return self.record is not None + @property + def summary(self): + return ' :: '.join([ + f"[ {self.record.id} ]", + self.record.name, + f"http://{os.environ['HOST']}/{self.slug}", + ]) + @property def slug(self) -> Union[str, None]: return self._slug @@ -50,6 +60,7 @@ class Playlist: self._record = self.session.query(db.playlist).filter(db.playlist.c.slug == self.slug).one() logging.debug(f"Retrieved playlist {self._record.id}") except NoResultFound: + logging.debug(f"Could not find a playlist with slug {self.slug}.") pass if not self._record and self._create_if_not_exists: self._record = self._create() @@ -62,7 +73,7 @@ class Playlist: """ Cache the list of entries on this playlist and return it. """ - if not self._entries and self.record: + if self.record and not self._entries: query = self.session.query( db.entry, db.track @@ -72,8 +83,11 @@ class Playlist: db.entry.c.playlist_id == db.playlist.c.id ).filter( db.entry.c.track_id == db.track.c.id + ).order_by( + db.entry.c.track ) - self._entries = db.windowed_query(query, db.entry.c.track_id, 1000) + # self._entries = list(db.windowed_query(query, db.entry.c.track_id, 1000)) + self._entries = query.all() return self._entries @property @@ -87,6 +101,13 @@ class Playlist: playlist['entries'] = [dict(entry) for entry in self.entries] return playlist + @property + def as_string(self) -> str: + text = f"{self.summary}\n" + for entry in self.entries: + text += f" - {entry.track} {entry.artist} - {entry.title}\n" + return text + def add(self, paths: List[str]) -> int: """ Add entries to the playlist. Each path should match one and only one track in the database (case-insensitive). @@ -100,7 +121,7 @@ class Playlist: """ logging.debug(f"Attempting to add tracks matching: {paths}") try: - return self._create_entries(self._get_tracks_by_path(paths)) + return self.create_entries(self._get_tracks_by_path(paths)) except NoResultFound: logging.error("One or more of the specified paths do not match any tracks in the database.") return 0 @@ -133,7 +154,7 @@ class Playlist: """ return [self.session.query(db.track).filter(db.track.c.relpath.ilike(f"%{path}%")).one() for path in paths] - def _create_entries(self, tracks: List[Row]) -> int: + def create_entries(self, tracks: List[Row]) -> int: """ Append a list of tracks to a playlist by populating the entries table with records referencing the playlist and the specified tracks. @@ -173,4 +194,4 @@ class Playlist: return pl def __repr__(self): - return str(self.as_dict) + return self.as_string diff --git a/groove/shell.py b/groove/shell.py deleted file mode 100644 index 3a6ffe3..0000000 --- a/groove/shell.py +++ /dev/null @@ -1,65 +0,0 @@ -from prompt_toolkit import prompt -from prompt_toolkit.completion import Completer, Completion -from rich import print - -from groove import db -from groove import handlers -from groove.db.manager import database_manager -from groove.playlist import Playlist - - -class CommandProcessor(Completer): - - prompt = '' - - def __init__(self, session): - super(CommandProcessor, self).__init__() - self._session = session - self.playlist = None - self._handlers = dict(handlers.load(self)) - print(f"Loaded command handlers: {' '.join(self._handlers.keys())}") - - @property - def session(self): - return self._session - - def get_completions(self, document, complete_event): - word = document.get_word_before_cursor() - found = False - for command_name in self._handlers.keys(): - if word in command_name: - yield Completion(command_name, start_position=-len(word)) - found = True - if not found: - def _formatter(row): - self.playlist = Playlist.from_row(row, self._session) - return f'playlist {self.playlist.record.name}' - completer = handlers.FuzzyTableCompleter( - db.playlist, - db.playlist.c.name, - _formatter, - self._session - ) - for res in completer.get_completions(document, complete_event): - yield res - - def process(self, cmd): - if not cmd: - return - cmd, *parts = cmd.split() - if cmd in self._handlers: - self._handlers[cmd].handle(*parts) - - def start(self): - cmd = '' - while True: - cmd = prompt(f'{self.prompt} > ', completer=self) - self.process(cmd) - if not cmd: - self.cmd_exit() - - -def start_shell(): - print("Groove On Demand interactive shell.") - with database_manager() as manager: - CommandProcessor(manager.session).start() diff --git a/groove/shell/__init__.py b/groove/shell/__init__.py new file mode 100644 index 0000000..ef6e387 --- /dev/null +++ b/groove/shell/__init__.py @@ -0,0 +1,7 @@ +from .base import BasePrompt +from .quit import quit +from .help import help +from .browse import browse +from .stats import stats +from .playlist import _playlist +from .create import create diff --git a/groove/shell/base.py b/groove/shell/base.py new file mode 100644 index 0000000..4590cb0 --- /dev/null +++ b/groove/shell/base.py @@ -0,0 +1,74 @@ +from prompt_toolkit import prompt +from prompt_toolkit.completion import Completer, Completion + + +class BasePrompt(Completer): + + def __init__(self, manager=None, parent=None): + super(BasePrompt, self).__init__() + + if (not manager and not parent): + raise RuntimeError("Must define either a database manager or a parent object.") + + self._prompt = '' + self._values = [] + self._parent = parent + self._manager = manager + + @property + def usage(self): + return self.__class__.__name__ + + @property + def help_text(self): + return self.__doc__ + + @property + def manager(self): + if self._manager: + return self._manager + elif self._parent: + return self._parent.manager + + @property + def parent(self): + return self._parent + + @property + def prompt(self): + return self._prompt + + @property + def values(self): + return self._values + + def get_completions(self, document, complete_event): + word = document.get_word_before_cursor() + found = False + for value in self.values: + if word in value: + found = True + yield Completion(value, start_position=-len(word)) + if not found: + try: + for result in self.default_completer(document, complete_event): + yield result + except NotImplementedError: + pass + + def start(self, cmd=''): + while True: + if not cmd: + cmd = prompt(f'{self.prompt} ', completer=self) + if not cmd: + return + cmd, *parts = cmd.split() + if not self.process(cmd, *parts): + return + cmd = '' + + def default_completer(self, document, complete_event): + raise NotImplementedError() + + def process(self, cmd, *parts): + raise NotImplementedError() diff --git a/groove/shell/browse.py b/groove/shell/browse.py new file mode 100644 index 0000000..233fdc1 --- /dev/null +++ b/groove/shell/browse.py @@ -0,0 +1,26 @@ +from .base import BasePrompt + +from rich.table import Table, Column +from rich import print + +from sqlalchemy import func +from groove import db +from groove.playlist import Playlist + + +class browse(BasePrompt): + """Browse the playlists.""" + + def process(self, cmd, *parts): + count = self.parent.manager.session.query(func.count(db.playlist.c.id)).scalar() + print(f"Displaying {count} playlists:") + query = self.parent.manager.session.query(db.playlist) + table = Table( + *[Column(k.name.title()) for k in db.playlist.columns] + ) + for row in db.windowed_query(query, db.playlist.c.id, 1000): + columns = tuple(Playlist.from_row(row, self.manager.session).record)[0:-1] + table.add_row(*[str(col) for col in columns]) + print() + print(table) + print() diff --git a/groove/shell/create.py b/groove/shell/create.py new file mode 100644 index 0000000..bb081ca --- /dev/null +++ b/groove/shell/create.py @@ -0,0 +1,27 @@ +from .base import BasePrompt + +from slugify import slugify + +from groove.playlist import Playlist + + +class create(BasePrompt): + """Create a new playlist.""" + + @property + def usage(self): + return "create PLAYLIST_NAME" + + def process(self, cmd, *parts): + name = ' '.join(parts) + if not name: + print(f"Usage: {self.usage}") + return + slug = slugify(name) + self.parent._playlist = Playlist( + slug=slug, + name=name, + session=self.manager.session, + create_if_not_exists=True + ) + return self.parent.commands['_playlist'].start() diff --git a/groove/shell/help.py b/groove/shell/help.py new file mode 100644 index 0000000..ed6328d --- /dev/null +++ b/groove/shell/help.py @@ -0,0 +1,26 @@ +from .base import BasePrompt + +from rich import print +import rich.table + + +class help(BasePrompt): + """Display help documentation.""" + + @property + def usage(self): + return "help [COMMAND]" + + def process(self, cmd, *parts): + if not parts: + print("Available Commands:") + table = rich.table.Table() + table.add_column("Command", style="yellow", no_wrap=True) + table.add_column("Description") + for name, obj in self.parent.commands.items(): + if name.startswith('_'): + continue + table.add_row(getattr(obj, 'usage', name), obj.__doc__) + print(table) + else: + print(f"Help for {parts}:") diff --git a/groove/shell/interactive_shell.py b/groove/shell/interactive_shell.py new file mode 100644 index 0000000..0181b95 --- /dev/null +++ b/groove/shell/interactive_shell.py @@ -0,0 +1,68 @@ +from rich import print +from slugify import slugify + +from groove.db.manager import database_manager +from groove.shell.base import BasePrompt +from groove import db +from groove.playlist import Playlist + + +class CommandPrompt(BasePrompt): + + def __init__(self, manager): + super().__init__(manager=manager) + self._playlist = None + self._prompt = "Groove on Demand interactive shell. Try 'help' for help.\ngroove>" + self._completer = None + self._commands = None + + @property + def playlist(self): + return self._playlist + + @property + def commands(self): + if not self._commands: + self._commands = {} + for cmd in BasePrompt.__subclasses__(): + if cmd.__name__ == self.__class__.__name__: + continue + self._commands[cmd.__name__] = cmd(manager=self.manager, parent=self) + return self._commands + + @property + def values(self): + return [k for k in self.commands.keys() if not k.startswith('_')] + + def default_completer(self, document, complete_event): + def _formatter(row): + self._playlist = Playlist.from_row(row, self.manager) + return self.playlist.record.name + return self.manager.fuzzy_table_completer( + db.playlist, + db.playlist.c.name, + _formatter + ).get_completions(document, complete_event) + + def process(self, cmd, *parts): + name = cmd + ' ' + ' '.join(parts) + if cmd in self.commands: + self.commands[cmd].start(name) + elif not parts: + print(f"Command not understood: {cmd}") + else: + slug = slugify(name) + self._playlist = Playlist( + slug=slug, + name=name, + session=self.manager.session, + create_if_not_exists=False + ) + self.commands['_playlist'].start() + self._playlist = None + return True + + +def start(): + with database_manager() as manager: + CommandPrompt(manager).start() diff --git a/groove/shell/playlist.py b/groove/shell/playlist.py new file mode 100644 index 0000000..1907e96 --- /dev/null +++ b/groove/shell/playlist.py @@ -0,0 +1,86 @@ +from .base import BasePrompt + +from prompt_toolkit import prompt +from rich import print +from sqlalchemy.exc import NoResultFound + +from groove import db + + +class _playlist(BasePrompt): + + def __init__(self, parent, manager=None): + super().__init__(manager=manager, parent=parent) + self._parent = parent + self._prompt = '' + self._commands = None + + @property + def prompt(self): + return f"{self.parent.playlist}\n{self.parent.playlist.slug}> " + + @property + def values(self): + return self.commands.keys() + + @property + def commands(self): + if not self._commands: + self._commands = { + 'show': self.show, + 'delete': self.delete, + 'add': self.add, + } + return self._commands + + def process(self, cmd, *parts): + res = True + if cmd in self.commands: + res = self.commands[cmd](parts) + else: + print(f"Command not understood: {cmd}") + return res is True + + def show(self, parts): + print(self.parent.playlist) + return True + + def add(self, parts): + print("Add tracks one at a time by title. ENTER to finish.") + while True: + text = prompt( + ' ? ', + completer=self.manager.fuzzy_table_completer( + db.track, + db.track.c.relpath, + lambda row: row.relpath + ), + complete_in_thread=True, complete_while_typing=True + ) + if not text: + return True + self._add_track(text) + + def _add_track(self, text): + sess = self.parent.manager.session + try: + track = sess.query(db.track).filter(db.track.c.relpath == text).one() + self.parent.playlist.create_entries([track]) + except NoResultFound: + print("No match for '{text}'") + return + return text + + def delete(self, parts): + res = prompt( + 'Type DELETE to permanently delete the playlist ' + f'"{self.parent.playlist.record.name}".\nDELETE {self.prompt}' + ) + if res != 'DELETE': + print("Delete aborted. No changes have been made.") + return True + + self.parent.playlist.delete() + print("Deleted the playlist.") + self.parent._playlist = None + return False diff --git a/groove/shell/quit.py b/groove/shell/quit.py new file mode 100644 index 0000000..db6b829 --- /dev/null +++ b/groove/shell/quit.py @@ -0,0 +1,8 @@ +from .base import BasePrompt + + +class quit(BasePrompt): + """Exit the interactive shell.""" + + def process(self, cmd, *parts): + raise SystemExit() diff --git a/groove/shell/stats.py b/groove/shell/stats.py new file mode 100644 index 0000000..964a0b7 --- /dev/null +++ b/groove/shell/stats.py @@ -0,0 +1,16 @@ +from .base import BasePrompt + +from sqlalchemy import func +from rich import print + +from groove import db + + +class stats(BasePrompt): + + def process(self, cmd, *parts): + sess = self.parent.manager.session + playlists = sess.query(func.count(db.playlist.c.id)).scalar() + entries = sess.query(func.count(db.entry.c.track)).scalar() + tracks = sess.query(func.count(db.track.c.relpath)).scalar() + print(f"Database contains {playlists} playlists with a total of {entries} entries, from {tracks} known tracks.")