WIP addition of interactive shell
This commit is contained in:
parent
7ca1f69100
commit
10122063f3
|
@ -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()
|
||||
|
|
|
@ -18,3 +18,4 @@ def windowed_query(query, column, window_size):
|
|||
last_id = chunk[-1][-1]
|
||||
for row in chunk:
|
||||
yield row
|
||||
|
||||
|
|
|
@ -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:
|
||||
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)
|
||||
async def _do_import():
|
||||
logging.debug("Scanning filesystem (this may take a minute)...")
|
||||
for path in sources:
|
||||
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
|
||||
|
|
|
@ -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
105
groove/handlers.py
Normal 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
65
groove/shell.py
Normal 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()
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user