WIP interactive shell

This commit is contained in:
evilchili 2022-11-30 00:09:23 -08:00
parent 10122063f3
commit 26b7fde93d
14 changed files with 389 additions and 178 deletions

View File

@ -10,7 +10,7 @@ from rich import print
import rich.table import rich.table
from groove import webserver from groove import webserver
from groove.shell import start_shell from groove.shell import interactive_shell
from groove.playlist import Playlist from groove.playlist import Playlist
from groove import db from groove import db
from groove.db.manager import database_manager from groove.db.manager import database_manager
@ -156,7 +156,7 @@ def scan(
@app.command() @app.command()
def shell(): def shell():
initialize() initialize()
start_shell() interactive_shell.start()
@app.command() @app.command()

View File

@ -1,11 +1,30 @@
import os import os
from prompt_toolkit.completion import Completion, FuzzyCompleter
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from . import metadata 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: class DatabaseManager:
""" """
A context manager for working with sqllite database. A context manager for working with sqllite database.
@ -18,7 +37,7 @@ class DatabaseManager:
@property @property
def engine(self): def engine(self):
if not self._engine: 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 return self._engine
@property @property
@ -31,6 +50,9 @@ class DatabaseManager:
def import_from_filesystem(self): def import_from_filesystem(self):
pass pass
def fuzzy_table_completer(self, table, column, formatter):
return FuzzyTableCompleter(table, column, formatter, session=self.session)
def __enter__(self): def __enter__(self):
metadata.create_all(bind=self.engine) metadata.create_all(bind=self.engine)
return self return self

View File

@ -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)

View File

@ -4,7 +4,9 @@ from sqlalchemy.orm.session import Session
from sqlalchemy.engine.row import Row from sqlalchemy.engine.row import Row
from sqlalchemy.exc import NoResultFound, MultipleResultsFound from sqlalchemy.exc import NoResultFound, MultipleResultsFound
from typing import Union, List from typing import Union, List
import logging import logging
import os
class Playlist: class Playlist:
@ -32,6 +34,14 @@ class Playlist:
""" """
return self.record is not None 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 @property
def slug(self) -> Union[str, None]: def slug(self) -> Union[str, None]:
return self._slug return self._slug
@ -50,6 +60,7 @@ class Playlist:
self._record = self.session.query(db.playlist).filter(db.playlist.c.slug == self.slug).one() self._record = self.session.query(db.playlist).filter(db.playlist.c.slug == self.slug).one()
logging.debug(f"Retrieved playlist {self._record.id}") logging.debug(f"Retrieved playlist {self._record.id}")
except NoResultFound: except NoResultFound:
logging.debug(f"Could not find a playlist with slug {self.slug}.")
pass pass
if not self._record and self._create_if_not_exists: if not self._record and self._create_if_not_exists:
self._record = self._create() self._record = self._create()
@ -62,7 +73,7 @@ class Playlist:
""" """
Cache the list of entries on this playlist and return it. 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( query = self.session.query(
db.entry, db.entry,
db.track db.track
@ -72,8 +83,11 @@ class Playlist:
db.entry.c.playlist_id == db.playlist.c.id db.entry.c.playlist_id == db.playlist.c.id
).filter( ).filter(
db.entry.c.track_id == db.track.c.id 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 return self._entries
@property @property
@ -87,6 +101,13 @@ class Playlist:
playlist['entries'] = [dict(entry) for entry in self.entries] playlist['entries'] = [dict(entry) for entry in self.entries]
return playlist 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: 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). 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}") logging.debug(f"Attempting to add tracks matching: {paths}")
try: try:
return self._create_entries(self._get_tracks_by_path(paths)) return self.create_entries(self._get_tracks_by_path(paths))
except NoResultFound: except NoResultFound:
logging.error("One or more of the specified paths do not match any tracks in the database.") logging.error("One or more of the specified paths do not match any tracks in the database.")
return 0 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] 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 Append a list of tracks to a playlist by populating the entries table with records referencing the playlist and
the specified tracks. the specified tracks.
@ -173,4 +194,4 @@ class Playlist:
return pl return pl
def __repr__(self): def __repr__(self):
return str(self.as_dict) return self.as_string

View File

@ -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()

7
groove/shell/__init__.py Normal file
View File

@ -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

74
groove/shell/base.py Normal file
View File

@ -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()

26
groove/shell/browse.py Normal file
View File

@ -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()

27
groove/shell/create.py Normal file
View File

@ -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()

26
groove/shell/help.py Normal file
View File

@ -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}:")

View File

@ -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()

86
groove/shell/playlist.py Normal file
View File

@ -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

8
groove/shell/quit.py Normal file
View File

@ -0,0 +1,8 @@
from .base import BasePrompt
class quit(BasePrompt):
"""Exit the interactive shell."""
def process(self, cmd, *parts):
raise SystemExit()

16
groove/shell/stats.py Normal file
View File

@ -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.")