2022-12-06 22:17:53 -08:00
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
|
|
|
|
from typing import Union, List
|
|
|
|
|
2022-11-24 13:45:09 -08:00
|
|
|
from groove import db
|
2022-12-06 22:17:53 -08:00
|
|
|
from groove.editor import PlaylistEditor, EDITOR_TEMPLATE
|
|
|
|
from groove.exceptions import PlaylistImportError
|
|
|
|
|
|
|
|
from slugify import slugify
|
2022-11-24 13:45:09 -08:00
|
|
|
from sqlalchemy import func, delete
|
2022-11-25 12:20:43 -08:00
|
|
|
from sqlalchemy.orm.session import Session
|
|
|
|
from sqlalchemy.engine.row import Row
|
|
|
|
from sqlalchemy.exc import NoResultFound, MultipleResultsFound
|
2022-11-24 13:45:09 -08:00
|
|
|
|
|
|
|
|
|
|
|
class Playlist:
|
|
|
|
"""
|
|
|
|
CRUD operations and convenience methods for playlists.
|
|
|
|
"""
|
2022-11-25 15:40:24 -08:00
|
|
|
def __init__(self,
|
|
|
|
slug: str,
|
|
|
|
session: Session,
|
|
|
|
name: str = '',
|
|
|
|
description: str = '',
|
2022-12-02 00:21:19 -08:00
|
|
|
create_ok=True):
|
2022-11-25 12:20:43 -08:00
|
|
|
self._session = session
|
2022-11-24 13:45:09 -08:00
|
|
|
self._slug = slug
|
2022-11-25 15:40:24 -08:00
|
|
|
self._name = name
|
|
|
|
self._description = description
|
2022-11-24 13:45:09 -08:00
|
|
|
self._entries = None
|
2022-12-02 00:21:19 -08:00
|
|
|
self._record = None
|
|
|
|
self._create_ok = create_ok
|
|
|
|
self._deleted = False
|
2022-12-06 22:17:53 -08:00
|
|
|
self._editor = PlaylistEditor()
|
2022-12-02 00:21:19 -08:00
|
|
|
|
|
|
|
@property
|
|
|
|
def deleted(self) -> bool:
|
|
|
|
return self._deleted
|
2022-11-24 13:45:09 -08:00
|
|
|
|
|
|
|
@property
|
2022-11-25 12:20:43 -08:00
|
|
|
def exists(self) -> bool:
|
2022-12-02 00:21:19 -08:00
|
|
|
if self.deleted:
|
2022-12-02 21:43:51 -08:00
|
|
|
logging.debug("Playlist has been deleted.")
|
2022-12-02 00:21:19 -08:00
|
|
|
return False
|
|
|
|
if not self._record:
|
2022-12-02 21:43:51 -08:00
|
|
|
if self._create_ok:
|
|
|
|
return True and self.record
|
|
|
|
return False
|
2022-12-02 00:21:19 -08:00
|
|
|
return True
|
2022-11-24 13:45:09 -08:00
|
|
|
|
2022-12-06 22:17:53 -08:00
|
|
|
@property
|
|
|
|
def editor(self):
|
|
|
|
return self._editor
|
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self):
|
|
|
|
return self._name
|
|
|
|
|
|
|
|
@property
|
|
|
|
def description(self):
|
|
|
|
return self._description
|
|
|
|
|
2022-11-30 00:09:23 -08:00
|
|
|
@property
|
|
|
|
def summary(self):
|
|
|
|
return ' :: '.join([
|
|
|
|
f"[ {self.record.id} ]",
|
|
|
|
self.record.name,
|
|
|
|
f"http://{os.environ['HOST']}/{self.slug}",
|
|
|
|
])
|
|
|
|
|
2022-11-24 13:45:09 -08:00
|
|
|
@property
|
2022-12-02 00:21:19 -08:00
|
|
|
def slug(self) -> str:
|
2022-11-24 13:45:09 -08:00
|
|
|
return self._slug
|
|
|
|
|
|
|
|
@property
|
2022-12-02 00:21:19 -08:00
|
|
|
def session(self) -> Session:
|
2022-11-25 12:20:43 -08:00
|
|
|
return self._session
|
2022-11-24 13:45:09 -08:00
|
|
|
|
|
|
|
@property
|
2022-11-25 12:20:43 -08:00
|
|
|
def record(self) -> Union[Row, None]:
|
|
|
|
"""
|
|
|
|
Cache the playlist row from the database and return it. Optionally create it if it doesn't exist.
|
|
|
|
"""
|
2022-11-24 13:45:09 -08:00
|
|
|
if not self._record:
|
2022-12-02 00:21:19 -08:00
|
|
|
self._record = self.get_or_create()
|
2022-11-24 13:45:09 -08:00
|
|
|
return self._record
|
|
|
|
|
|
|
|
@property
|
2022-11-25 12:20:43 -08:00
|
|
|
def entries(self) -> Union[List, None]:
|
|
|
|
"""
|
|
|
|
Cache the list of entries on this playlist and return it.
|
|
|
|
"""
|
2022-11-30 00:09:23 -08:00
|
|
|
if self.record and not self._entries:
|
2022-11-25 15:40:24 -08:00
|
|
|
query = self.session.query(
|
2022-11-24 13:45:09 -08:00
|
|
|
db.entry,
|
|
|
|
db.track
|
|
|
|
).filter(
|
|
|
|
(db.playlist.c.id == self.record.id)
|
|
|
|
).filter(
|
|
|
|
db.entry.c.playlist_id == db.playlist.c.id
|
|
|
|
).filter(
|
|
|
|
db.entry.c.track_id == db.track.c.id
|
2022-11-30 00:09:23 -08:00
|
|
|
).order_by(
|
|
|
|
db.entry.c.track
|
2022-11-25 15:40:24 -08:00
|
|
|
)
|
2022-11-30 00:09:23 -08:00
|
|
|
self._entries = query.all()
|
2022-11-24 13:45:09 -08:00
|
|
|
return self._entries
|
|
|
|
|
|
|
|
@property
|
|
|
|
def as_dict(self) -> dict:
|
|
|
|
"""
|
2022-11-25 12:20:43 -08:00
|
|
|
Return a dictionary of the playlist and its entries.
|
2022-11-24 13:45:09 -08:00
|
|
|
"""
|
2022-12-02 00:21:19 -08:00
|
|
|
if not self.exists:
|
2022-11-25 12:20:43 -08:00
|
|
|
return {}
|
2022-11-24 13:45:09 -08:00
|
|
|
playlist = dict(self.record)
|
|
|
|
playlist['entries'] = [dict(entry) for entry in self.entries]
|
|
|
|
return playlist
|
|
|
|
|
2022-11-30 00:09:23 -08:00
|
|
|
@property
|
|
|
|
def as_string(self) -> str:
|
2022-12-02 00:21:19 -08:00
|
|
|
if not self.exists:
|
|
|
|
return ''
|
2022-11-30 00:09:23 -08:00
|
|
|
text = f"{self.summary}\n"
|
|
|
|
for entry in self.entries:
|
|
|
|
text += f" - {entry.track} {entry.artist} - {entry.title}\n"
|
|
|
|
return text
|
|
|
|
|
2022-12-06 22:17:53 -08:00
|
|
|
@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)
|
|
|
|
|
2022-12-02 00:21:19 -08:00
|
|
|
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
|
|
|
|
MultipleResultsFound are expected in the case of no matches and multiple matches, respectively.
|
|
|
|
"""
|
|
|
|
return [self.session.query(db.track).filter(db.track.c.relpath.ilike(f"%{path}%")).one() for path in paths]
|
|
|
|
|
2022-12-06 22:17:53 -08:00
|
|
|
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()
|
|
|
|
|
2022-11-25 12:20:43 -08:00
|
|
|
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).
|
|
|
|
If a path doesn't match any track, or if a path matches multiple tracks, nothing is added to the playlist.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
paths (list): A list of partial paths to add.
|
2022-11-24 13:45:09 -08:00
|
|
|
|
2022-11-25 12:20:43 -08:00
|
|
|
Returns:
|
|
|
|
int: The number of tracks added.
|
|
|
|
"""
|
2022-11-25 15:40:24 -08:00
|
|
|
logging.debug(f"Attempting to add tracks matching: {paths}")
|
2022-11-25 12:20:43 -08:00
|
|
|
try:
|
2022-11-30 00:09:23 -08:00
|
|
|
return self.create_entries(self._get_tracks_by_path(paths))
|
2022-11-25 12:20:43 -08:00
|
|
|
except NoResultFound:
|
|
|
|
logging.error("One or more of the specified paths do not match any tracks in the database.")
|
|
|
|
return 0
|
|
|
|
except MultipleResultsFound:
|
|
|
|
logging.error("One or more of the specified paths matches multiple tracks in the database.")
|
|
|
|
return 0
|
|
|
|
|
|
|
|
def delete(self) -> Union[int, None]:
|
|
|
|
"""
|
|
|
|
Delete a playlist and its entries from the database, then clear the cached values.
|
|
|
|
"""
|
|
|
|
if not self.record:
|
|
|
|
return None
|
2022-11-24 13:45:09 -08:00
|
|
|
plid = self.record.id
|
|
|
|
stmt = delete(db.entry).where(db.entry.c.playlist_id == plid)
|
|
|
|
logging.debug(f"Deleting entries associated with playlist {plid}: {stmt}")
|
2022-11-25 12:20:43 -08:00
|
|
|
self.session.execute(stmt)
|
2022-11-24 13:45:09 -08:00
|
|
|
stmt = delete(db.playlist).where(db.playlist.c.id == plid)
|
|
|
|
logging.debug(f"Deleting playlist {plid}: {stmt}")
|
2022-11-25 12:20:43 -08:00
|
|
|
self.session.execute(stmt)
|
|
|
|
self.session.commit()
|
|
|
|
self._record = None
|
|
|
|
self._entries = None
|
2022-12-02 00:21:19 -08:00
|
|
|
self._deleted = True
|
2022-11-24 13:45:09 -08:00
|
|
|
return plid
|
|
|
|
|
2022-12-02 00:21:19 -08:00
|
|
|
def get_or_create(self, create_ok: bool = False) -> Row:
|
|
|
|
try:
|
2022-12-06 22:17:53 -08:00
|
|
|
return self._get()
|
2022-12-02 00:21:19 -08:00
|
|
|
except NoResultFound:
|
|
|
|
logging.debug(f"Could not find a playlist with slug {self.slug}.")
|
|
|
|
if self.deleted:
|
|
|
|
raise RuntimeError("Object has been deleted.")
|
|
|
|
if self._create_ok or create_ok:
|
|
|
|
return self.save()
|
|
|
|
|
2022-12-06 22:17:53 -08:00
|
|
|
def _get(self):
|
|
|
|
return self.session.query(db.playlist).filter(
|
|
|
|
db.playlist.c.slug == self.slug
|
|
|
|
).one()
|
2022-12-02 21:43:51 -08:00
|
|
|
|
2022-12-06 22:17:53 -08:00
|
|
|
def _insert(self, values):
|
|
|
|
stmt = db.playlist.insert(values)
|
2022-12-02 00:21:19 -08:00
|
|
|
results = self.session.execute(stmt)
|
|
|
|
self.session.commit()
|
2022-12-06 22:17:53 -08:00
|
|
|
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):
|
|
|
|
self.get_or_create(create_ok=False)
|
|
|
|
return self
|
2022-11-25 12:20:43 -08:00
|
|
|
|
2022-11-30 00:09:23 -08:00
|
|
|
def create_entries(self, tracks: List[Row]) -> int:
|
2022-11-25 12:20:43 -08:00
|
|
|
"""
|
|
|
|
Append a list of tracks to a playlist by populating the entries table with records referencing the playlist and
|
|
|
|
the specified tracks.
|
2022-11-24 13:45:09 -08:00
|
|
|
|
2022-11-25 12:20:43 -08:00
|
|
|
Args:
|
|
|
|
tracks (list): A list of Row objects from the track table.
|
2022-11-24 13:45:09 -08:00
|
|
|
|
2022-11-25 12:20:43 -08:00
|
|
|
Returns:
|
|
|
|
int: The number of tracks added.
|
|
|
|
"""
|
2022-12-02 00:21:19 -08:00
|
|
|
maxtrack = self.session.query(func.max(db.entry.c.track)).filter_by(
|
|
|
|
playlist_id=self.record.id
|
|
|
|
).one()[0] or 0
|
|
|
|
|
2022-11-25 12:20:43 -08:00
|
|
|
self.session.execute(
|
2022-11-24 13:45:09 -08:00
|
|
|
db.entry.insert(),
|
|
|
|
[
|
|
|
|
{'playlist_id': self.record.id, 'track_id': obj.id, 'track': idx}
|
|
|
|
for (idx, obj) in enumerate(tracks, start=maxtrack+1)
|
|
|
|
]
|
|
|
|
)
|
2022-11-25 12:20:43 -08:00
|
|
|
self.session.commit()
|
2022-11-25 15:40:24 -08:00
|
|
|
self._entries = None
|
2022-11-24 13:45:09 -08:00
|
|
|
return len(tracks)
|
|
|
|
|
2022-11-25 15:40:24 -08:00
|
|
|
@classmethod
|
|
|
|
def from_row(cls, row, session):
|
|
|
|
pl = Playlist(slug=row.slug, session=session)
|
|
|
|
pl._record = row
|
|
|
|
return pl
|
|
|
|
|
2022-12-06 22:17:53 -08:00
|
|
|
@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
|
|
|
|
|
2022-11-25 15:40:24 -08:00
|
|
|
def __repr__(self):
|
2022-11-30 00:09:23 -08:00
|
|
|
return self.as_string
|