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
|
import rich.table
|
||||||
|
|
||||||
from groove import webserver
|
from groove import webserver
|
||||||
|
from groove.shell import start_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
|
||||||
from groove.db.scanner import media_scanner
|
from groove.db.scanner import media_scanner
|
||||||
|
|
||||||
|
|
||||||
playlist_app = typer.Typer()
|
playlist_app = typer.Typer()
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
app.add_typer(playlist_app, name='playlist', help='Manage playlists.')
|
app.add_typer(playlist_app, name='playlist', help='Manage playlists.')
|
||||||
|
@ -150,7 +150,13 @@ def scan(
|
||||||
with database_manager() as manager:
|
with database_manager() as manager:
|
||||||
scanner = media_scanner(root=root, db=manager.session)
|
scanner = media_scanner(root=root, db=manager.session)
|
||||||
count = scanner.scan()
|
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()
|
@app.command()
|
||||||
|
|
|
@ -18,3 +18,4 @@ def windowed_query(query, column, window_size):
|
||||||
last_id = chunk[-1][-1]
|
last_id = chunk[-1][-1]
|
||||||
for row in chunk:
|
for row in chunk:
|
||||||
yield row
|
yield row
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import music_tag
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Union, Iterable
|
from typing import Callable, Union, Iterable
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
@ -39,12 +42,24 @@ class MediaScanner:
|
||||||
return self.root.rglob(pattern) # pragma: no cover
|
return self.root.rglob(pattern) # pragma: no cover
|
||||||
|
|
||||||
def import_tracks(self, sources: Iterable) -> None:
|
def import_tracks(self, sources: Iterable) -> None:
|
||||||
|
async def _do_import():
|
||||||
|
logging.debug("Scanning filesystem (this may take a minute)...")
|
||||||
for path in sources:
|
for path in sources:
|
||||||
relpath = str(path.relative_to(self.root))
|
asyncio.create_task(self._import_one_track(path))
|
||||||
stmt = groove.db.track.insert({'relpath': relpath}).prefix_with('OR IGNORE')
|
asyncio.run(_do_import())
|
||||||
self.db.execute(stmt)
|
|
||||||
self.db.commit()
|
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:
|
def scan(self) -> int:
|
||||||
"""
|
"""
|
||||||
Walk the media root and insert Track table entries for each media file
|
Walk the media root and insert Track table entries for each media file
|
||||||
|
|
|
@ -8,6 +8,8 @@ track = Table(
|
||||||
metadata,
|
metadata,
|
||||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
Column("relpath", UnicodeText, index=True, unique=True),
|
Column("relpath", UnicodeText, index=True, unique=True),
|
||||||
|
Column("artist", UnicodeText),
|
||||||
|
Column("title", UnicodeText),
|
||||||
)
|
)
|
||||||
|
|
||||||
playlist = Table(
|
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"
|
python-slugify = "^7.0.0"
|
||||||
rich = "^12.6.0"
|
rich = "^12.6.0"
|
||||||
bottle-sqlalchemy = "^0.4.3"
|
bottle-sqlalchemy = "^0.4.3"
|
||||||
|
music-tag = "^0.4.3"
|
||||||
|
prompt-toolkit = "^3.0.33"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^7.2.0"
|
pytest = "^7.2.0"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user