diff --git a/groove/cli.py b/groove/cli.py index 4b13ef4..8424921 100644 --- a/groove/cli.py +++ b/groove/cli.py @@ -106,6 +106,7 @@ def scan( initialize() with database_manager() as manager: scanner = media_scanner(root=root, db=manager.session) + scanner.cleanup() count = scanner.scan() logging.info(f"Imported {count} new tracks.") diff --git a/groove/db/scanner.py b/groove/db/scanner.py index b97d74f..9c61e73 100644 --- a/groove/db/scanner.py +++ b/groove/db/scanner.py @@ -5,7 +5,7 @@ import music_tag from pathlib import Path from typing import Callable, Union, Iterable -from sqlalchemy import func +from sqlalchemy import func, delete import groove.db import groove.path @@ -40,10 +40,31 @@ class MediaScanner: 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)) + if path.exists() and not path.is_dir(): + asyncio.create_task(self._import_one_track(path)) asyncio.run(_do_import()) self.db.commit() + def cleanup(self) -> int: + """ + Check for the existence of every track in the databse. + """ + async def _del(track): + path = self.root / Path(track.relpath) + if path.exists(): + return + logging.info(f"Deleting missing track {track.relpath}") + self.db.execute( + delete(groove.db.track).where(groove.db.track.c.id == track.id) + ) + + async def _do_cleanup(): + logging.debug("Locating stale track definitions in the database...") + for track in self.db.query(groove.db.track).all(): + asyncio.create_task(_del(track)) + asyncio.run(_do_cleanup()) + self.db.commit() + def _get_tags(self, path): # pragma: no cover tags = music_tag.load_file(path) return { diff --git a/groove/playlist.py b/groove/playlist.py index 13b0ac4..212ebe0 100644 --- a/groove/playlist.py +++ b/groove/playlist.py @@ -66,7 +66,7 @@ class Playlist: @property def info(self): count = len(self.entries) - return f"{self.name}: {self.url} [{count} tracks]\n{self.description}\n" + return f"{self.name}: {self.url} [{count} tracks]\n{self.description}" @property def url(self) -> str: @@ -133,7 +133,7 @@ class Playlist: @property def as_string(self) -> str: - text = self.info + text = self.info + self.description for (tracknum, entry) in enumerate(self.entries): text += f" - {tracknum+1} {entry.artist} - {entry.title}\n" return text diff --git a/groove/shell/base.py b/groove/shell/base.py index f6eb614..f3c9ce6 100644 --- a/groove/shell/base.py +++ b/groove/shell/base.py @@ -14,6 +14,11 @@ class BasePrompt(Completer): self._values = [] self._parent = parent self._manager = manager + self._commands = {'help': self.help} + + @property + def commands(self): + return self._commands @property def usage(self): @@ -40,7 +45,7 @@ class BasePrompt(Completer): @property def values(self): - return self._values + return [k for k in self.commands.keys() if not k.startswith('_')] def get_completions(self, document, complete_event): word = document.get_word_before_cursor() @@ -59,7 +64,7 @@ class BasePrompt(Completer): def start(self, cmd=''): while True: if not cmd: - cmd = prompt(f'{self.prompt} ', completer=self) + cmd = prompt(f'{self.prompt} ', completer=self, complete_while_typing=True) if not cmd: return cmd, *parts = cmd.split() @@ -67,6 +72,13 @@ class BasePrompt(Completer): return cmd = '' + def help(self, parts): + if not parts: + print(self.__doc__) + else: + print(getattr(self, parts[0]).__doc__) + return True + def default_completer(self, document, complete_event): raise NotImplementedError() diff --git a/groove/shell/interactive_shell.py b/groove/shell/interactive_shell.py index 68bfaba..82cc896 100644 --- a/groove/shell/interactive_shell.py +++ b/groove/shell/interactive_shell.py @@ -54,8 +54,8 @@ class CommandPrompt(BasePrompt): session=self.manager.session, create_ok=True ) - res = self.commands['_playlist'].start() - return True and res + self.commands['_playlist'].start() + return True def start(): # pragma: no cover diff --git a/groove/shell/playlist.py b/groove/shell/playlist.py index 11e495d..9616d1a 100644 --- a/groove/shell/playlist.py +++ b/groove/shell/playlist.py @@ -8,21 +8,14 @@ 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 + """ + PLAYLIST + """ @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: @@ -31,9 +24,20 @@ class _playlist(BasePrompt): 'delete': self.delete, 'add': self.add, 'edit': self.edit, + 'help': self.help } return self._commands + 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 process(self, cmd, *parts): res = True if cmd in self.commands: @@ -66,16 +70,6 @@ class _playlist(BasePrompt): 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 ' diff --git a/test/fixtures/themes/alt_theme/static b/test/fixtures/themes/alt_theme/static new file mode 100644 index 0000000..e69de29 diff --git a/test/test_scanner.py b/test/test_scanner.py index 3630572..69b0ae1 100644 --- a/test/test_scanner.py +++ b/test/test_scanner.py @@ -38,6 +38,10 @@ def test_scanner(monkeypatch, in_memory_db, media): # replace the filesystem glob with the test fixture generator monkeypatch.setattr(scanner.MediaScanner, 'find_sources', MagicMock(return_value=media())) + # pretend things exist + monkeypatch.setattr(scanner.Path, 'exists', MagicMock(return_value=True)) + monkeypatch.setattr(scanner.Path, 'is_dir', MagicMock(return_value=False)) + def mock_loader(path): return { 'artist': 'foo', diff --git a/test/test_shell.py b/test/test_shell.py index acf65b5..2200bde 100644 --- a/test/test_shell.py +++ b/test/test_shell.py @@ -16,6 +16,10 @@ def response_factory(responses): return MagicMock(side_effect=responses + ([''] * 10)) +def test_commands(cmd_prompt): + assert cmd_prompt.commands.keys() == cmd_prompt.commands.keys() + + @pytest.mark.parametrize('inputs, expected', [ (['stats'], 'Database contains 4 playlists'), # match the db fixture ])