Added editor for name/slug/description

This commit is contained in:
evilchili 2022-12-06 22:17:53 -08:00
parent e036eff4e2
commit 3fdd3ee9a5
6 changed files with 178 additions and 16 deletions

63
groove/editor.py Normal file
View File

@ -0,0 +1,63 @@
import logging
import os
import subprocess
import yaml
from tempfile import NamedTemporaryFile
EDITOR_TEMPLATE = """
{name}:
description: {description}
entries:
{entries}
# ------------------------------------------------------------------------------
#
# Groove On Demand Playlist Editor
#
# This file is in YAML format. Blank lines and lines beginning with # are
# ignored. Here's a complete example:
#
# My Awesome Jams, Vol. 2:
# description: |
# These jams are totally awesome, yo.
# Totally.
#
# yo.
# entries:
# - Beastie Boys - Help Me, Ronda
# - Bob and Doug McKenzie - Messiah (Hallelujah Eh)
#
"""
class PlaylistEditor:
"""
A custom ConfigParser that only supports specific headers and ignores all other square brackets.
"""
def __init__(self):
self._path = None
@property
def path(self):
if not self._path:
self._path = NamedTemporaryFile(prefix='groove_on_demand-', delete=False)
return self._path
def edit(self, playlist):
with self.path as fh:
fh.write(playlist.as_yaml.encode())
subprocess.check_call([os.environ['EDITOR'], self.path.name])
edits = self.read()
self.cleanup()
return edits
def read(self):
with open(self.path.name, 'rb') as fh:
return yaml.safe_load(fh)
def cleanup(self):
if self._path:
os.unlink(self._path.name)
self._path = None

View File

@ -21,3 +21,9 @@ class ConfigurationError(Exception):
""" """
An error was discovered with the Groove on Demand configuration. An error was discovered with the Groove on Demand configuration.
""" """
class PlaylistImportError(Exception):
"""
An error was discovered in a playlist template.
"""

View File

@ -1,12 +1,17 @@
import logging
import os
from typing import Union, List
from groove import db from groove import db
from groove.editor import PlaylistEditor, EDITOR_TEMPLATE
from groove.exceptions import PlaylistImportError
from slugify import slugify
from sqlalchemy import func, delete from sqlalchemy import func, delete
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.engine.row import Row from sqlalchemy.engine.row import Row
from sqlalchemy.exc import NoResultFound, MultipleResultsFound from sqlalchemy.exc import NoResultFound, MultipleResultsFound
from typing import Union, List
import logging
import os
class Playlist: class Playlist:
@ -27,6 +32,7 @@ class Playlist:
self._record = None self._record = None
self._create_ok = create_ok self._create_ok = create_ok
self._deleted = False self._deleted = False
self._editor = PlaylistEditor()
@property @property
def deleted(self) -> bool: def deleted(self) -> bool:
@ -43,6 +49,18 @@ class Playlist:
return False return False
return True return True
@property
def editor(self):
return self._editor
@property
def name(self):
return self._name
@property
def description(self):
return self._description
@property @property
def summary(self): def summary(self):
return ' :: '.join([ return ' :: '.join([
@ -109,6 +127,14 @@ class Playlist:
text += f" - {entry.track} {entry.artist} - {entry.title}\n" text += f" - {entry.track} {entry.artist} - {entry.title}\n"
return text return text
@property
def as_yaml(self) -> str:
template_vars = self.as_dict
template_vars['entries'] = ''
for entry in self.entries:
template_vars['entries'] += f" - {entry.artist} - {entry.title}\n"
return EDITOR_TEMPLATE.format(**template_vars)
def _get_tracks_by_path(self, paths: List[str]) -> List: def _get_tracks_by_path(self, paths: List[str]) -> List:
""" """
Retrieve tracks from the database that match the specified path fragments. The exceptions NoResultFound and Retrieve tracks from the database that match the specified path fragments. The exceptions NoResultFound and
@ -116,6 +142,20 @@ class Playlist:
""" """
return [self.session.query(db.track).filter(db.track.c.relpath.ilike(f"%{path}%")).one() for path in paths] return [self.session.query(db.track).filter(db.track.c.relpath.ilike(f"%{path}%")).one() for path in paths]
def edit(self):
edits = self.editor.edit(self)
if not edits:
return
new = Playlist.from_yaml(edits, self.session)
if new == self:
logging.debug("No changes detected.")
return
logging.debug(f"Updating {self.slug} with new edits.")
self._slug = new.slug
self._name = new.name.strip()
self._description = new.description.strip()
self._record = self.save()
def add(self, paths: List[str]) -> int: def add(self, paths: List[str]) -> int:
""" """
Add entries to the playlist. Each path should match one and only one track in the database (case-insensitive). Add entries to the playlist. Each path should match one and only one track in the database (case-insensitive).
@ -158,7 +198,7 @@ class Playlist:
def get_or_create(self, create_ok: bool = False) -> Row: def get_or_create(self, create_ok: bool = False) -> Row:
try: try:
return self.session.query(db.playlist).filter(db.playlist.c.slug == self.slug).one() return self._get()
except NoResultFound: except NoResultFound:
logging.debug(f"Could not find a playlist with slug {self.slug}.") logging.debug(f"Could not find a playlist with slug {self.slug}.")
if self.deleted: if self.deleted:
@ -166,18 +206,45 @@ class Playlist:
if self._create_ok or create_ok: if self._create_ok or create_ok:
return self.save() return self.save()
def _get(self):
return self.session.query(db.playlist).filter(
db.playlist.c.slug == self.slug
).one()
def _insert(self, values):
stmt = db.playlist.insert(values)
results = self.session.execute(stmt)
self.session.commit()
logging.debug(f"Saved playlist with slug {self.slug}")
return self.session.query(db.playlist).filter(
db.playlist.c.id == results.inserted_primary_key[0]
).one()
def _update(self, values):
stmt = db.playlist.update().where(
db.playlist.c.id == self._record.id
).values(values)
self.session.execute(stmt)
self.session.commit()
return self.session.query(db.playlist).filter(
db.playlist.c.id == self._record.id
).one()
def save(self) -> Row:
values = {
'slug': self.slug,
'name': self.name,
'description': self.description
}
logging.debug(f"Saving values: {values}")
obj = self._update(values) if self._record else self._insert(values)
logging.debug(f"Saved playlist {obj.id} with slug {obj.slug}")
return obj
def load(self): def load(self):
self.get_or_create(create_ok=False) self.get_or_create(create_ok=False)
return self return self
def save(self) -> Row:
keys = {'slug': self.slug, 'name': self._name, 'description': self._description}
stmt = db.playlist.update(keys) if self._record else db.playlist.insert(keys)
results = self.session.execute(stmt)
self.session.commit()
logging.debug(f"Saved playlist {results.inserted_primary_key[0]} with slug {self.slug}")
return self.session.query(db.playlist).filter(db.playlist.c.id == results.inserted_primary_key[0]).one()
def create_entries(self, tracks: List[Row]) -> int: def create_entries(self, tracks: List[Row]) -> int:
""" """
Append a list of tracks to a playlist by populating the entries table with records referencing the playlist and Append a list of tracks to a playlist by populating the entries table with records referencing the playlist and
@ -210,5 +277,26 @@ class Playlist:
pl._record = row pl._record = row
return pl return pl
@classmethod
def from_yaml(cls, source, session):
try:
name = list(source.keys())[0].strip()
description = (source[name]['description'] or '').strip()
return Playlist(
slug=slugify(name),
name=name,
description=description,
session=session,
)
except (IndexError, KeyError):
PlaylistImportError("The specified source was not a valid playlist.")
def __eq__(self, obj):
for key in ('slug', 'name', 'description'):
if getattr(obj, key) != getattr(self, key):
logging.debug(f"{key}: {getattr(obj, key)} != {getattr(self, key)}")
return False
return True
def __repr__(self): def __repr__(self):
return self.as_string return self.as_string

View File

@ -48,9 +48,8 @@ class CommandPrompt(BasePrompt):
if cmd in self.commands: if cmd in self.commands:
self.commands[cmd].start(name) self.commands[cmd].start(name)
else: else:
slug = slugify(name)
self._playlist = Playlist( self._playlist = Playlist(
slug=slug, slug=slugify(name),
name=name, name=name,
session=self.manager.session, session=self.manager.session,
create_ok=True create_ok=True

View File

@ -30,6 +30,7 @@ class _playlist(BasePrompt):
'show': self.show, 'show': self.show,
'delete': self.delete, 'delete': self.delete,
'add': self.add, 'add': self.add,
'edit': self.edit,
} }
return self._commands return self._commands
@ -45,6 +46,10 @@ class _playlist(BasePrompt):
print(self.parent.playlist) print(self.parent.playlist)
return True return True
def edit(self, parts):
self.parent.playlist.edit()
return True
def add(self, parts): def add(self, parts):
print("Add tracks one at a time by title. ENTER to finish.") print("Add tracks one at a time by title. ENTER to finish.")
while True: while True:

View File

@ -20,6 +20,7 @@ rich = "^12.6.0"
bottle-sqlalchemy = "^0.4.3" bottle-sqlalchemy = "^0.4.3"
music-tag = "^0.4.3" music-tag = "^0.4.3"
prompt-toolkit = "^3.0.33" prompt-toolkit = "^3.0.33"
PyYAML = "^6.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.2.0" pytest = "^7.2.0"