WIP addition of interactive shell

This commit is contained in:
evilchili 2022-11-27 18:42:46 -08:00
parent 7ca1f69100
commit 10122063f3
7 changed files with 202 additions and 6 deletions

View File

@ -10,12 +10,12 @@ from rich import print
import rich.table
from groove import webserver
from groove.shell import start_shell
from groove.playlist import Playlist
from groove import db
from groove.db.manager import database_manager
from groove.db.scanner import media_scanner
playlist_app = typer.Typer()
app = typer.Typer()
app.add_typer(playlist_app, name='playlist', help='Manage playlists.')
@ -150,7 +150,13 @@ def scan(
with database_manager() as manager:
scanner = media_scanner(root=root, db=manager.session)
count = scanner.scan()
logging.info(f"Imported {count} new tracks from {root}.")
logging.info(f"Imported {count} new tracks.")
@app.command()
def shell():
initialize()
start_shell()
@app.command()

View File

@ -18,3 +18,4 @@ def windowed_query(query, column, window_size):
last_id = chunk[-1][-1]
for row in chunk:
yield row

View File

@ -1,7 +1,10 @@
import asyncio
import logging
import os
import sys
import music_tag
from pathlib import Path
from typing import Callable, Union, Iterable
from sqlalchemy import func
@ -39,12 +42,24 @@ class MediaScanner:
return self.root.rglob(pattern) # pragma: no cover
def import_tracks(self, sources: Iterable) -> None:
async def _do_import():
logging.debug("Scanning filesystem (this may take a minute)...")
for path in sources:
relpath = str(path.relative_to(self.root))
stmt = groove.db.track.insert({'relpath': relpath}).prefix_with('OR IGNORE')
self.db.execute(stmt)
asyncio.create_task(self._import_one_track(path))
asyncio.run(_do_import())
self.db.commit()
async def _import_one_track(self, path):
tags = music_tag.load_file(path)
relpath = str(path.relative_to(self.root))
stmt = groove.db.track.insert({
'relpath': relpath,
'artist': str(tags.resolve('album_artist')),
'title': str(tags['title']),
}).prefix_with('OR IGNORE')
logging.debug(f"{tags['artist']} - {tags['title']}")
self.db.execute(stmt)
def scan(self) -> int:
"""
Walk the media root and insert Track table entries for each media file

View File

@ -8,6 +8,8 @@ track = Table(
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("relpath", UnicodeText, index=True, unique=True),
Column("artist", UnicodeText),
Column("title", UnicodeText),
)
playlist = Table(

105
groove/handlers.py Normal file
View File

@ -0,0 +1,105 @@
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)

65
groove/shell.py Normal file
View File

@ -0,0 +1,65 @@
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()

View File

@ -18,6 +18,8 @@ SQLAlchemy = "^1.4.44"
python-slugify = "^7.0.0"
rich = "^12.6.0"
bottle-sqlalchemy = "^0.4.3"
music-tag = "^0.4.3"
prompt-toolkit = "^3.0.33"
[tool.poetry.dev-dependencies]
pytest = "^7.2.0"