diff --git a/groove/cli.py b/groove/cli.py index f80207c..4ebf2a6 100644 --- a/groove/cli.py +++ b/groove/cli.py @@ -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() diff --git a/groove/db/helpers.py b/groove/db/helpers.py index 0fb5bb9..5a28d60 100644 --- a/groove/db/helpers.py +++ b/groove/db/helpers.py @@ -18,3 +18,4 @@ def windowed_query(query, column, window_size): last_id = chunk[-1][-1] for row in chunk: yield row + diff --git a/groove/db/scanner.py b/groove/db/scanner.py index 3c13ebf..c3b2463 100644 --- a/groove/db/scanner.py +++ b/groove/db/scanner.py @@ -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 diff --git a/groove/db/schema.py b/groove/db/schema.py index ad45f89..d928b7e 100644 --- a/groove/db/schema.py +++ b/groove/db/schema.py @@ -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( diff --git a/groove/handlers.py b/groove/handlers.py new file mode 100644 index 0000000..e2093b0 --- /dev/null +++ b/groove/handlers.py @@ -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) diff --git a/groove/shell.py b/groove/shell.py new file mode 100644 index 0000000..3a6ffe3 --- /dev/null +++ b/groove/shell.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 5fbbd43..4d79a40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"