diff --git a/app/classes.py b/app/classes.py index ea86092..e219c9b 100644 --- a/app/classes.py +++ b/app/classes.py @@ -46,6 +46,54 @@ def singleton(cls): return wrapper_singleton +# DTOs +@dataclass +class PlaylistDTO: + playlist_id: int + name: str + open: bool = False + favourite: bool = False + is_template: bool = False + + +@dataclass +class QueryDTO: + query_id: int + name: str + favourite: bool + filter: Filter + + +@dataclass +class TrackDTO: + track_id: int + artist: str + bitrate: int + duration: int + fade_at: int + intro: int | None + path: str + silence_at: int + start_gap: int + title: str + lastplayed: dt.datetime | None + + +@dataclass +class PlaylistRowDTO(TrackDTO): + note: str + played: bool + playlist_id: int + playlistrow_id: int + row_number: int + + +@dataclass +class PlaydatesDTO(TrackDTO): + playdate_id: int + lastplayed: dt.datetime + + class ApplicationError(Exception): """ Custom exception @@ -124,39 +172,6 @@ class Tags(NamedTuple): duration: int = 0 -@dataclass -class PlaylistDTO: - name: str - playlist_id: int - favourite: bool = False - is_template: bool = False - open: bool = False - - -@dataclass -class TrackDTO: - track_id: int - artist: str - bitrate: int - duration: int - fade_at: int - intro: int | None - path: str - silence_at: int - start_gap: int - title: str - lastplayed: dt.datetime | None - - -@dataclass -class PlaylistRowDTO(TrackDTO): - note: str - played: bool - playlist_id: int - playlistrow_id: int - row_number: int - - class TrackInfo(NamedTuple): track_id: int row_number: int @@ -177,6 +192,12 @@ class InsertTrack: note: str +@dataclass +class PlayTrack: + playlist_id: int + track_id: int + + @singleton @dataclass class MusicMusterSignals(QObject): @@ -204,6 +225,7 @@ class MusicMusterSignals(QObject): # specify that here as it requires us to import PlaylistRow from # playlistrow.py, which itself imports MusicMusterSignals signal_set_next_track = pyqtSignal(object) + signal_track_started = pyqtSignal(PlayTrack) span_cells_signal = pyqtSignal(int, int, int, int, int) status_message_signal = pyqtSignal(str, int) track_ended_signal = pyqtSignal() diff --git a/app/config.py b/app/config.py index 58db885..946204e 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,7 @@ class Config(object): COLOUR_QUERYLIST_SELECTED = "#d3ffd3" COLOUR_UNREADABLE = "#dc3545" COLOUR_WARNING_TIMER = "#ffc107" + DB_NOT_FOUND = "Database not found" DBFS_SILENCE = -50 DEFAULT_COLUMN_WIDTH = 200 DISPLAY_SQL = False diff --git a/app/dialogs.py b/app/dialogs.py index 6adb4a1..2907d20 100644 --- a/app/dialogs.py +++ b/app/dialogs.py @@ -2,13 +2,7 @@ from typing import Optional # PyQt imports -from PyQt6.QtCore import QEvent, Qt -from PyQt6.QtGui import QKeyEvent -from PyQt6.QtWidgets import ( - QDialog, - QListWidgetItem, - QMainWindow, -) +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QDialog, QHBoxLayout, @@ -16,6 +10,7 @@ from PyQt6.QtWidgets import ( QLineEdit, QListWidget, QListWidgetItem, + QMainWindow, QPushButton, QVBoxLayout, ) @@ -98,12 +93,10 @@ class TrackInsertDialog(QDialog): self.setLayout(layout) self.resize(800, 600) - # TODO - # record = Settings.get_setting(self.session, "dbdialog_width") - # width = record.f_int or 800 - # record = Settings.get_setting(self.session, "dbdialog_height") - # height = record.f_int or 600 - # self.resize(width, height) + + width = repository.get_setting("dbdialog_width") or 800 + height = repository.get_setting("dbdialog_height") or 800 + self.resize(width, height) self.signals = MusicMusterSignals() @@ -114,9 +107,9 @@ class TrackInsertDialog(QDialog): return if text.startswith("a/") and len(text) > 2: - self.tracks = repository.tracks_like_artist(text[2:]) + self.tracks = repository.tracks_by_artist(text[2:]) else: - self.tracks = repository.tracks_like_title(text) + self.tracks = repository.tracks_by_title(text) for track in self.tracks: duration_str = ms_to_mmss(track.duration) diff --git a/app/file_importer.py b/app/file_importer.py index db23e63..fc71642 100644 --- a/app/file_importer.py +++ b/app/file_importer.py @@ -465,7 +465,7 @@ class FileImporter: # file). Check that because the path field in the database is # unique and so adding a duplicate will give a db integrity # error. - if repository.track_with_path(tfd.destination_path): + if repository.track_by_path(tfd.destination_path): tfd.error = ( "Importing a new track but destination path already exists " f"in database ({tfd.destination_path})" diff --git a/app/helpers.py b/app/helpers.py index 8551c74..66a6232 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -331,32 +331,6 @@ def normalise_track(path: str) -> None: os.remove(temp_path) -def remove_substring_case_insensitive(parent_string: str, substring: str) -> str: - """ - Remove all instances of substring from parent string, case insensitively - """ - - # Convert both strings to lowercase for case-insensitive comparison - lower_parent = parent_string.lower() - lower_substring = substring.lower() - - # Initialize the result string - result = parent_string - - # Continue removing the substring until it's no longer found - while lower_substring in lower_parent: - # Find the index of the substring - index = lower_parent.find(lower_substring) - - # Remove the substring - result = result[:index] + result[index + len(substring) :] - - # Update the lowercase versions - lower_parent = result.lower() - - return result - - def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None: # From https://docs.python.org/3/library/email.examples.html diff --git a/app/musicmuster.py b/app/musicmuster.py index 0b7fba9..a1a5545 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -59,7 +59,6 @@ from PyQt6.QtWidgets import ( # Third party imports # import line_profiler from pygame import mixer -from sqlalchemy.orm.session import Session import stackprinter # type: ignore # App imports @@ -67,6 +66,8 @@ from classes import ( ApplicationError, Filter, MusicMusterSignals, + PlayTrack, + QueryDTO, TrackInfo, ) from config import Config @@ -78,6 +79,7 @@ from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tr from playlistrow import PlaylistRow, TrackSequence from playlistmodel import PlaylistModel, PlaylistProxyModel from playlists import PlaylistTab +import repository from querylistmodel import QuerylistModel from ui import icons_rc # noqa F401 from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore @@ -455,10 +457,9 @@ class ManageQueries(ItemlistManager): Delete / edit queries """ - def __init__(self, session: Session, musicmuster: Window) -> None: + def __init__(self, musicmuster: Window) -> None: super().__init__() - self.session = session self.musicmuster = musicmuster self.refresh_table() self.exec() @@ -471,9 +472,11 @@ class ManageQueries(ItemlistManager): # Build a list of queries query_list: list[ItemlistItem] = [] - for query in Queries.get_all(self.session): + for query in repository.get_all_queries(): query_list.append( - ItemlistItem(name=query.name, id=query.id, favourite=query.favourite) + ItemlistItem( + name=query.name, id=query.query_id, favourite=query.favourite + ) ) self.populate_table(query_list) @@ -482,7 +485,7 @@ class ManageQueries(ItemlistManager): def delete_item(self, query_id: int) -> None: """delete query""" - query = self.session.get(Queries, query_id) + query = repository.query_by_id(query_id) if not query: raise ApplicationError( f"manage_template.delete({query_id=}) can't load query" @@ -491,24 +494,22 @@ class ManageQueries(ItemlistManager): "Delete query", f"Delete query '{query.name}': " "Are you sure?", ): - self.session.delete(query) - self.session.commit() + repository.delete_query(query_id) self.refresh_table() - def _edit_item(self, query: Queries) -> None: + def _edit_item(self, query: QueryDTO) -> None: """Edit query""" dlg = FilterDialog(query.name, query.filter) if dlg.exec(): - query.filter = dlg.filter - query.name = dlg.name_text.text() - self.session.commit() + repository.update_query_filter(query.query_id, dlg.filter) + repository.update_query_name(query.query_id, dlg.name_text.text()) def edit_item(self, query_id: int) -> None: """Edit query""" - query = self.session.get(Queries, query_id) + query = repository.query_by_id(query_id) if not query: raise ApplicationError( f"manage_template.edit_item({query_id=}) can't load query" @@ -518,11 +519,7 @@ class ManageQueries(ItemlistManager): def toggle_favourite(self, query_id: int, favourite: bool) -> None: """Mark query as (not) favourite""" - query = self.session.get(Queries, query_id) - if not query: - return - query.favourite = favourite - self.session.commit() + repository.update_query_favourite(query_id, favourite) def new_item(self) -> None: """Create new query""" @@ -531,24 +528,21 @@ class ManageQueries(ItemlistManager): if not query_name: return - query = Queries(self.session, query_name, Filter()) + query = repository.create_query(query_name, Filter()) self._edit_item(query) self.refresh_table() def rename_item(self, query_id: int) -> None: """rename query""" - query = self.session.get(Queries, query_id) + query = repository.query_by_id(query_id) if not query: - raise ApplicationError( - f"manage_template.delete({query_id=}) can't load query" - ) + raise ApplicationError(f"Can't load query ({query_id=})") new_name = get_name(prompt="New query name", default=query.name) if not new_name: return - query.name = new_name - self.session.commit() + repository.update_query_name(query_id, new_name) self.change_text(query_id, new_name) @@ -558,10 +552,9 @@ class ManageTemplates(ItemlistManager): Delete / edit templates """ - def __init__(self, session: Session, musicmuster: Window) -> None: + def __init__(self, musicmuster: Window) -> None: super().__init__() - self.session = session self.musicmuster = musicmuster self.refresh_table() self.exec() @@ -574,10 +567,12 @@ class ManageTemplates(ItemlistManager): # Build a list of templates template_list: list[ItemlistItem] = [] - for template in Playlists.get_all_templates(self.session): + for template in repository.playlists_templates(): template_list.append( ItemlistItem( - name=template.name, id=template.id, favourite=template.favourite + name=template.name, + id=template.playlist_id, + favourite=template.favourite, ) ) @@ -587,7 +582,7 @@ class ManageTemplates(ItemlistManager): def delete_item(self, template_id: int) -> None: """delete template""" - template = self.session.get(Playlists, template_id) + template = repository.playlists_template_by_id(template_id) if not template: raise ApplicationError( f"manage_template.delete({template_id=}) can't load template" @@ -607,13 +602,12 @@ class ManageTemplates(ItemlistManager): else: self.musicmuster.playlist_section.tabPlaylist.removeTab(open_idx) - self.session.delete(template) - self.session.commit() + repository.delete_playlist(template.playlist_id) def edit_item(self, template_id: int) -> None: """Edit template""" - template = self.session.get(Playlists, template_id) + template = repository.playlists_template_by_id(template_id) if not template: raise ApplicationError( f"manage_template.edit({template_id=}) can't load template" @@ -625,11 +619,7 @@ class ManageTemplates(ItemlistManager): def toggle_favourite(self, template_id: int, favourite: bool) -> None: """Mark template as (not) favourite""" - template = self.session.get(Playlists, template_id) - if not template: - return - template.favourite = favourite - self.session.commit() + repository.update_template_favourite(template_id, favourite) def new_item( self, @@ -638,22 +628,18 @@ class ManageTemplates(ItemlistManager): # Get base template template_id = self.musicmuster.solicit_template_to_use( - self.session, template_prompt="New template based upon:" + template_prompt="New template based upon:" ) if template_id is None: return # Get new template name - name = self.musicmuster.get_playlist_name( - self.session, default="", prompt="New template name:" - ) + name = self.musicmuster.get_playlist_name(default="", prompt="New template name:") if not name: return # Create playlist for template and mark is as a template - template = self.musicmuster._create_playlist(self.session, name, template_id) - template.is_template = True - self.session.commit() + template = repository.create_playlist(name, template_id, as_template=True) # Open it for editing self.musicmuster._open_playlist(template, is_template=True) @@ -661,19 +647,14 @@ class ManageTemplates(ItemlistManager): def rename_item(self, template_id: int) -> None: """rename template""" - template = self.session.get(Playlists, template_id) + template = repository.playlist_by_id(template_id) if not template: raise ApplicationError( f"manage_template.delete({template_id=}) can't load template" ) - new_name = self.musicmuster.get_playlist_name(self.session, template.name) - if not new_name: - return - - template.name = new_name - self.session.commit() - - self.change_text(template_id, new_name) + new_name = self.musicmuster.get_playlist_name(template.name) + if new_name: + repository.playlist_rename(template_id, new_name) @dataclass @@ -790,9 +771,8 @@ class PreviewManager: class QueryDialog(QDialog): """Dialog box to handle selecting track from a query""" - def __init__(self, session: Session, default: int = 0) -> None: + def __init__(self, default: int = 0) -> None: super().__init__() - self.session = session self.default = default # Build a list of (query-name, playlist-id) tuples @@ -800,8 +780,8 @@ class QueryDialog(QDialog): self.query_list: list[tuple[str, int]] = [] self.query_list.append((Config.NO_QUERY_NAME, 0)) - for query in Queries.get_all(self.session): - self.query_list.append((query.name, query.id)) + for query in repository.get_all_queries(): + self.query_list.append((query.name, query.query_id)) self.setWindowTitle("Query Selector") @@ -918,8 +898,7 @@ class QueryDialog(QDialog): querylist_y=self.y(), ) for name, value in attributes_to_save.items(): - record = Settings.get_setting(self.session, name) - record.f_int = value + repository.set_setting(name, value) header = self.table_view.horizontalHeader() if header is None: @@ -929,10 +908,9 @@ class QueryDialog(QDialog): return for column_number in range(column_count - 1): attr_name = f"querylist_col_{column_number}_width" - record = Settings.get_setting(self.session, attr_name) - record.f_int = self.table_view.columnWidth(column_number) - - self.session.commit() + repository.set_setting( + attr_name, self.table_view.columnWidth(column_number) + ) def _column_resize(self, column_number: int, _old: int, _new: int) -> None: """ @@ -959,14 +937,14 @@ class QueryDialog(QDialog): Called when user selects query """ - # Get query id + # Get query query_id = self.combo_box.currentData() - query = self.session.get(Queries, query_id) + query = repository.query_by_id(query_id) if not query: return # Create model - base_model = QuerylistModel(self.session, query.filter) + base_model = QuerylistModel(query.filter) # Create table self.table_view.setModel(base_model) @@ -983,10 +961,10 @@ class QueryDialog(QDialog): def set_window_size(self) -> None: """Set window sizes""" - x = Settings.get_setting(self.session, "querylist_x").f_int or 100 - y = Settings.get_setting(self.session, "querylist_y").f_int or 100 - width = Settings.get_setting(self.session, "querylist_width").f_int or 100 - height = Settings.get_setting(self.session, "querylist_height").f_int or 100 + x = repository.get_setting("querylist_x") or 100 + y = repository.get_setting("querylist_y") or 100 + width = repository.get_setting("querylist_width") or 100 + height = repository.get_setting("querylist_height") or 100 self.setGeometry(x, y, width, height) def set_column_sizes(self) -> None: @@ -1002,17 +980,12 @@ class QueryDialog(QDialog): # Last column is set to stretch so ignore it here for column_number in range(column_count - 1): attr_name = f"querylist_col_{column_number}_width" - record = Settings.get_setting(self.session, attr_name) - if record.f_int is not None: - self.table_view.setColumnWidth(column_number, record.f_int) - else: - self.table_view.setColumnWidth( - column_number, Config.DEFAULT_COLUMN_WIDTH - ) + width = repository.get_setting(attr_name) or Config.DEFAULT_COLUMN_WIDTH + self.table_view.setColumnWidth(column_number, width) class SelectPlaylistDialog(QDialog): - def __init__(self, parent=None, playlists=None, session=None): + def __init__(self, parent=None, playlists=None): super().__init__() if playlists is None: @@ -1022,13 +995,10 @@ class SelectPlaylistDialog(QDialog): self.ui.lstPlaylists.itemDoubleClicked.connect(self.list_doubleclick) self.ui.buttonBox.accepted.connect(self.open) self.ui.buttonBox.rejected.connect(self.close) - self.session = session self.playlist = None - record = Settings.get_setting(self.session, "select_playlist_dialog_width") - width = record.f_int or 800 - record = Settings.get_setting(self.session, "select_playlist_dialog_height") - height = record.f_int or 600 + width = repository.get_setting("select_playlist_dialog_width") or 800 + height = repository.get_setting("select_playlist_dialog_height") or 800 self.resize(width, height) for playlist in playlists: @@ -1038,13 +1008,8 @@ class SelectPlaylistDialog(QDialog): self.ui.lstPlaylists.addItem(p) def __del__(self): # review - record = Settings.get_setting(self.session, "select_playlist_dialog_height") - record.f_int = self.height() - - record = Settings.get_setting(self.session, "select_playlist_dialog_width") - record.f_int = self.width() - - self.session.commit() + repository.set_setting("select_playlist_dialog_height", self.height()) + repository.set_setting("select_playlist_dialog_width", self.width()) def list_doubleclick(self, entry): # review self.playlist = entry.data(Qt.ItemDataRole.UserRole) @@ -1224,34 +1189,26 @@ class Window(QMainWindow): self, "Track playing", "Can't close application while track is playing" ) else: - with db.Session() as session: - # Save tab number of open playlists - open_playlist_ids: dict[int, int] = {} - for idx in range(self.playlist_section.tabPlaylist.count()): - open_playlist_ids[ - self.playlist_section.tabPlaylist.widget(idx).playlist_id - ] = idx - Playlists.clear_tabs(session, list(open_playlist_ids.keys())) - for playlist_id, idx in open_playlist_ids.items(): - playlist = session.get(Playlists, playlist_id) - if playlist: - playlist.tab = idx + # Save tab number of open playlists + playlist_id_to_tab: dict[int, int] = {} + for idx in range(self.playlist_section.tabPlaylist.count()): + playlist_id_to_tab[ + self.playlist_section.tabPlaylist.widget(idx).playlist_id + ] = idx + repository.playlist_save_tabs(playlist_id_to_tab) - # Save window attributes - attributes_to_save = dict( - mainwindow_height=self.height(), - mainwindow_width=self.width(), - mainwindow_x=self.x(), - mainwindow_y=self.y(), - active_tab=self.playlist_section.tabPlaylist.currentIndex(), - ) - for name, value in attributes_to_save.items(): - record = Settings.get_setting(session, name) - record.f_int = value + # Save window attributes + attributes_to_save = dict( + mainwindow_height=self.height(), + mainwindow_width=self.width(), + mainwindow_x=self.x(), + mainwindow_y=self.y(), + active_tab=self.playlist_section.tabPlaylist.currentIndex(), + ) + for name, value in attributes_to_save.items(): + repository.set_setting(name, value) - session.commit() - - event.accept() + event.accept() # # # # # # # # # # Internal utility functions # # # # # # # # # # @@ -1347,87 +1304,78 @@ class Window(QMainWindow): def get_new_playlist_dynamic_submenu_items( self, - ) -> list[dict[str, str | tuple[Session, int] | bool]]: + ) -> list[dict[str, str | int | bool]]: """ Return dynamically generated menu items, in this case templates marked as favourite from which to generate a new playlist. - The handler is to call create_playlist with a session - and template_id. + The handler is to call create_playlist_from_template with a dict + of arguments. """ - with db.Session() as session: - submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ + submenu_items: list[dict[str, str | int | bool]] = [ + { + "text": "Show all", + "handler": "create_playlist_from_template", + "args": (0), + }, + { + "separator": True, + }, + ] + templates = repository.playlists_templates() + for template in templates: + submenu_items.append( { - "text": "Show all", + "text": template.name, "handler": "create_playlist_from_template", - "args": (session, 0), - }, - { - "separator": True, - }, - ] - templates = Playlists.get_favourite_templates(session) - for template in templates: - submenu_items.append( - { - "text": template.name, - "handler": "create_playlist_from_template", - "args": ( - session, - template.id, - ), - } - ) + "args": template.playlist_id, + } + ) - return submenu_items + return submenu_items def get_query_dynamic_submenu_items( self, - ) -> list[dict[str, str | tuple[Session, int] | bool]]: + ) -> list[dict[str, str | int | bool]]: """ Return dynamically generated menu items, in this case templates marked as favourite from which to generate a new playlist. - The handler is to call show_query with a session - and query_id. + The handler is to call show_query with a query_id. """ - with db.Session() as session: - submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ + submenu_items: list[dict[str, str | int | bool]] = [ + { + "text": "Show all", + "handler": "show_query", + "args": 0, + }, + { + "separator": True, + }, + ] + queries = repository.get_all_queries(favourites_only=True) + for query in queries: + submenu_items.append( { - "text": "Show all", + "text": query.name, "handler": "show_query", - "args": (session, 0), - }, - { - "separator": True, - }, - ] - queries = Queries.get_favourites(session) - for query in queries: - submenu_items.append( - { - "text": query.name, - "handler": "show_query", - "args": ( - session, - query.id, - ), - } - ) + "args": query.query_id, + } + ) - return submenu_items + return submenu_items - def show_query(self, session: Session, query_id: int) -> None: + def show_query(self, query_id: int) -> None: """ Show query dialog with query_id selected """ # Keep a reference else it will be gc'd - self.query_dialog = QueryDialog(session, query_id) + self.query_dialog = QueryDialog(query_id) if self.query_dialog.exec(): new_row_number = self.current_row_or_end() base_model = self.current.base_model @@ -1444,7 +1392,9 @@ class Window(QMainWindow): move_existing = True if move_existing and existing_prd: - base_model.move_track_add_note(new_row_number, existing_prd, note="") + base_model.move_track_add_note( + new_row_number, existing_prd, note="" + ) else: base_model.insert_row(new_row_number, track_id) @@ -1453,15 +1403,13 @@ class Window(QMainWindow): # # # # # # # # # # Playlist management functions # # # # # # # # # # @log_call - def _create_playlist( - self, session: Session, name: str, template_id: int - ) -> Playlists: + def _create_playlist(self, name: str, template_id: int) -> Playlists: """ Create a playlist in the database, populate it from the template if template_id > 0, and return the Playlists object. """ - return Playlists(session, name, template_id) + return repository.create_playlist(name, template_id) @log_call def _open_playlist(self, playlist: Playlists, is_template: bool = False) -> int: @@ -1475,7 +1423,7 @@ class Window(QMainWindow): """ # Create base model and proxy model - base_model = PlaylistModel(playlist.id, is_template) + base_model = PlaylistModel(playlist.playlist_id, is_template) proxy_model = PlaylistProxyModel() proxy_model.setSourceModel(base_model) @@ -1484,7 +1432,7 @@ class Window(QMainWindow): idx = self.playlist_section.tabPlaylist.addTab(playlist_tab, playlist.name) # Mark playlist as open - playlist.mark_open() + repository.playlist_mark_status(playlist.playlist_id, open=True) # Switch to new tab self.playlist_section.tabPlaylist.setCurrentIndex(idx) @@ -1493,93 +1441,87 @@ class Window(QMainWindow): return idx @log_call - def create_playlist_from_template(self, session: Session, template_id: int) -> None: + def create_playlist_from_template(self, template_id: int) -> None: """ Prompt for new playlist name and create from passed template_id """ if template_id == 0: # Show all templates - selected_template_id = self.solicit_template_to_use(session) + selected_template_id = self.solicit_template_to_use() if selected_template_id is None: return else: template_id = selected_template_id - playlist_name = self.get_playlist_name(session) + playlist_name = self.get_playlist_name() if not playlist_name: return - playlist = self._create_playlist(session, playlist_name, template_id) - self._open_playlist(playlist) - session.commit() + _ = repository.create_playlist(playlist_name, template_id) @log_call def delete_playlist(self, checked: bool = False) -> None: """ - Delete current playlist + Delete current playlist. checked paramater passed by menu system + but unused. """ - with db.Session() as session: - playlist_id = self.current.playlist_id - playlist = session.get(Playlists, playlist_id) - if playlist: - if helpers.ask_yes_no( - "Delete playlist", - f"Delete playlist '{playlist.name}': " "Are you sure?", - ): - if self.close_playlist_tab(): - session.delete(playlist) - session.commit() - else: - log.error("Failed to retrieve playlist") + playlist = repository.playlist_by_id(self.current.playlist_id) + if playlist: + if helpers.ask_yes_no( + "Delete playlist", + f"Delete playlist '{playlist.name}': " "Are you sure?", + ): + if self.close_playlist_tab(): + repository.delete_playlist(self.current.playlist_id) + else: + log.error("Failed to retrieve playlist") def open_existing_playlist(self, checked: bool = False) -> None: """Open existing playlist""" - with db.Session() as session: - playlists = Playlists.get_closed(session) - dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) - dlg.exec() - playlist = dlg.playlist - if playlist: - self._open_playlist(playlist) - session.commit() + playlists = repository.playlists_closed() + dlg = SelectPlaylistDialog(self, playlists=playlists) + dlg.exec() + playlist = dlg.playlist + if playlist: + self._open_playlist(playlist) def save_as_template(self, checked: bool = False) -> None: """Save current playlist as template""" - with db.Session() as session: - template_names = [a.name for a in Playlists.get_all_templates(session)] + template_names = [a.name for a in repository.playlists_templates()] - while True: - # Get name for new template - dlg = QInputDialog(self) - dlg.setInputMode(QInputDialog.InputMode.TextInput) - dlg.setLabelText("Template name:") - dlg.resize(500, 100) - ok = dlg.exec() - if not ok: - return + while True: + # Get name for new template + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.InputMode.TextInput) + dlg.setLabelText("Template name:") + dlg.resize(500, 100) + ok = dlg.exec() + if not ok: + return - template_name = dlg.textValue() - if template_name not in template_names: - break - helpers.show_warning( - self, "Duplicate template", "Template name already in use" - ) - Playlists.save_as_template(session, self.current.playlist_id, template_name) - session.commit() - helpers.show_OK("Template", "Template saved", self) + template_name = dlg.textValue() + if template_name not in template_names: + break + helpers.show_warning( + self, "Duplicate template", "Template name already in use" + ) + repository.save_as_template(self.current.playlist_id, template_name) + helpers.show_OK("Template", "Template saved", self) def get_playlist_name( - self, session: Session, default: str = "", prompt: str = "Playlist name:" + self, default: str = "", prompt: str = "Playlist name:" ) -> Optional[str]: """Get a name from the user""" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setLabelText(prompt) + all_playlist_names = [a.name for a in repository.get_all_playlists()] + while True: if default: dlg.setTextValue(default) @@ -1587,7 +1529,7 @@ class Window(QMainWindow): ok = dlg.exec() if ok: proposed_name = dlg.textValue() - if Playlists.name_is_available(session, proposed_name): + if proposed_name not in all_playlist_names: return proposed_name else: helpers.show_warning( @@ -1600,7 +1542,7 @@ class Window(QMainWindow): return None def solicit_template_to_use( - self, session: Session, template_prompt: Optional[str] = None + self, template_prompt: Optional[str] = None ) -> Optional[int]: """ Have user select a template. Return the template.id, or None if they cancel. @@ -1610,15 +1552,14 @@ class Window(QMainWindow): template_name_id_list: list[tuple[str, int]] = [] template_name_id_list.append((Config.NO_TEMPLATE_NAME, 0)) - with db.Session() as session: - for template in Playlists.get_all_templates(session): - template_name_id_list.append((template.name, template.id)) + for template in repository.playlists_templates(): + template_name_id_list.append((template.name, template.playlist_id)) - dlg = TemplateSelectorDialog(template_name_id_list, template_prompt) - if not dlg.exec() or dlg.selected_id is None: - return None # User cancelled + dlg = TemplateSelectorDialog(template_name_id_list, template_prompt) + if not dlg.exec() or dlg.selected_id is None: + return None # User cancelled - return dlg.selected_id + return dlg.selected_id # # # # # # # # # # Manage templates and queries # # # # # # # # # # @@ -1627,16 +1568,14 @@ class Window(QMainWindow): Simply instantiate the manage_queries class """ - with db.Session() as session: - _ = ManageQueries(session, self) + _ = ManageQueries(self) def manage_templates_wrapper(self, checked: bool = False) -> None: """ - Simply instantiate the manage_queries class + Simply instantiate the manage_templates class """ - with db.Session() as session: - _ = ManageTemplates(session, self) + _ = ManageTemplates(self) # # # # # # # # # # Miscellaneous functions # # # # # # # # # # @@ -1655,9 +1594,7 @@ class Window(QMainWindow): except subprocess.CalledProcessError as exc_info: git_tag = str(exc_info.output) - with db.Session() as session: - if session.bind: - dbname = session.bind.engine.url.database + dbname = repository.get_db_name() QMessageBox.information( self, @@ -1723,11 +1660,8 @@ class Window(QMainWindow): ) return False - # Record playlist as closed and update remaining playlist tabs - with db.Session() as session: - playlist = session.get(Playlists, closing_tab_playlist_id) - if playlist: - playlist.close(session) + # Record playlist as closed + repository.playlist_mark_status(open=False) # Close playlist and remove tab self.playlist_section.tabPlaylist.widget(tab_index).close() @@ -1810,15 +1744,16 @@ class Window(QMainWindow): path += ".csv" with open(path, "w") as f: - with db.Session() as session: - for playdate in Playdates.played_after(session, start_dt): - f.write(f"{playdate.track.artist},{playdate.track.title}\n") + for playdate in repository.playdates_between_dates(start_dt): + f.write(f"{playdate.artist},{playdate.title}\n") def drop3db(self) -> None: """Drop music level by 3db if button checked""" if self.track_sequence.current: - self.track_sequence.current.drop3db(self.footer_section.btnDrop3db.isChecked()) + self.track_sequence.current.drop3db( + self.footer_section.btnDrop3db.isChecked() + ) @log_call def enable_escape(self, enabled: bool) -> None: @@ -1872,41 +1807,37 @@ class Window(QMainWindow): playlist_id = self.current.playlist_id - with db.Session() as session: - # Get output filename - playlist = session.get(Playlists, playlist_id) - if not playlist: - return + playlist = repository.playlist_by_id(playlist_id) + if not playlist: + return - pathspec = QFileDialog.getSaveFileName( - self, - "Save Playlist", - directory=f"{playlist.name}.m3u", - filter="M3U files (*.m3u);;All files (*.*)", - ) - if not pathspec: - return - path = pathspec[0] - if not path.endswith(".m3u"): - path += ".m3u" + # Get output filename + pathspec = QFileDialog.getSaveFileName( + self, + "Save Playlist", + directory=f"{playlist.name}.m3u", + filter="M3U files (*.m3u);;All files (*.*)", + ) + if not pathspec: + return + path = pathspec[0] + if not path.endswith(".m3u"): + path += ".m3u" - # Get list of track rows for this playlist - plrs = PlaylistRows.get_rows_with_tracks(session, playlist_id) - with open(path, "w") as f: - # Required directive on first line - f.write("#EXTM3U\n") - for track in [a.track for a in plrs]: - if track.duration is None: - track.duration = 0 - f.write( - "#EXTINF:" - f"{int(track.duration / 1000)}," - f"{track.title} - " - f"{track.artist}" - "\n" - f"{track.path}" - "\n" - ) + # Get list of track rows for this playlist + with open(path, "w") as f: + # Required directive on first line + f.write("#EXTM3U\n") + for playlistrow in repository.get_playlist_rows(playlist_id): + f.write( + "#EXTINF:" + f"{int(playlistrow.duration / 1000)}," + f"{playlistrow.title} - " + f"{playlistrow.artist}" + "\n" + f"{playlistrow.path}" + "\n" + ) def fade(self, checked: bool = False) -> None: """Fade currently playing track""" @@ -1972,34 +1903,23 @@ class Window(QMainWindow): def insert_track(self, checked: bool = False) -> None: """Show dialog box to select and add track from database""" - dlg = TrackInsertDialog( - parent=self, - playlist_id=self.active_tab().playlist_id - ) + dlg = TrackInsertDialog(parent=self, playlist_id=self.active_tab().playlist_id) dlg.exec() @log_call def load_last_playlists(self) -> None: - """Load the playlists that were open when the last session closed""" + """Load the playlists that were open when app was last closed""" playlist_ids = [] - with db.Session() as session: - for playlist in Playlists.get_open(session): - if playlist: - # Create tab - playlist_ids.append(self._open_playlist(playlist)) + for playlist in repository.playlists_open(): + if playlist: + # Create tab + playlist_ids.append(self._open_playlist(playlist)) - # Set active tab - record = Settings.get_setting(session, "active_tab") - if record.f_int is not None and record.f_int >= 0: - self.playlist_section.tabPlaylist.setCurrentIndex(record.f_int) - - # Tabs may move during use. Rather than track where tabs - # are, we record the tab index when we close the main - # window. To avoid possible duplicate tab entries, we null - # them all out now. - Playlists.clear_tabs(session, playlist_ids) - session.commit() + # Set active tab + value = repository.get_setting("active_tab") + if value is not None and value >= 0: + self.playlist_section.tabPlaylist.setCurrentIndex(value) def lookup_row_in_songfacts(self, checked: bool = False) -> None: """ @@ -2047,25 +1967,21 @@ class Window(QMainWindow): playlists = [] source_playlist_id = self.current.playlist_id - with db.Session() as session: - for playlist in Playlists.get_all(session): - if playlist.id == source_playlist_id: - continue - else: - playlists.append(playlist) - - dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) - dlg.exec() - if not dlg.playlist: - return - to_playlist_id = dlg.playlist.id - - # Get row number in destination playlist - last_row = PlaylistRows.get_last_used_row(session, to_playlist_id) - if last_row is not None: - to_row = last_row + 1 + for playlist in repository.get_all_playlists(): + if playlist.id == source_playlist_id: + continue else: - to_row = 0 + playlists.append(playlist) + + dlg = SelectPlaylistDialog(self, playlists=playlists) + dlg.exec() + if not dlg.playlist: + return + to_playlist_id = dlg.playlist.id + + # Add to end of target playlist, so target row will be length of + # playlist + to_row = repository.playlist_row_count(to_playlist_id) # Move rows self.current.base_model.move_rows_between_playlists( @@ -2153,7 +2069,9 @@ class Window(QMainWindow): to_playlist_model.set_next_row(set_next_row) @log_call - def play_next(self, position: Optional[float] = None, checked: bool = False) -> None: + def play_next( + self, position: Optional[float] = None, checked: bool = False + ) -> None: """ Play next track, optionally from passed position. @@ -2223,20 +2141,29 @@ class Window(QMainWindow): self.catch_return_key = True self.show_status_message("Play controls: Disabled", 0) - # Notify playlist - self.active_tab().current_track_started() + # Notify others + self.signals.signal_track_started.emit( + PlayTrack(self.track_sequence.current.playlist_id, + self.track_sequence.current.track_id) + ) + + # TODO: ensure signal_track_started does all this: + # self.active_tab().current_track_started() + # Update playdates + # Set toolips for hdrPreviousTrack (but let's do that dynamically + # on hover in future) + # with s-e-s-s-i-o-n: + # last_played = Playdates.last_played_tracks(s-e-s-s-i-o-n) + # tracklist = [] + # for lp in last_played: + # track = s-e-s-s-i-o-n.get(Tracks, lp.track_id) + # tracklist.append(f"{track.title} ({track.artist})") + # tt = "
".join(tracklist) + + # self.header_section.hdrPreviousTrack.setToolTip(tt) # Update headers self.update_headers() - with db.Session() as session: - last_played = Playdates.last_played_tracks(session) - tracklist = [] - for lp in last_played: - track = session.get(Tracks, lp.track_id) - tracklist.append(f"{track.title} ({track.artist})") - tt = "
".join(tracklist) - - self.header_section.hdrPreviousTrack.setToolTip(tt) def preview(self) -> None: """ @@ -2253,29 +2180,27 @@ class Window(QMainWindow): if self.track_sequence.next: if self.track_sequence.next.track_id: track_info = TrackInfo( - self.track_sequence.next.track_id, self.track_sequence.next.row_number + self.track_sequence.next.track_id, + self.track_sequence.next.row_number, ) else: return if not track_info: return self.preview_manager.row_number = track_info.row_number - with db.Session() as session: - track = session.get(Tracks, track_info.track_id) - if not track: - raise ApplicationError( - f"musicmuster.preview: unable to retreive track {track_info.track_id=}" - ) - self.preview_manager.set_track_info( - track_id=track.id, - track_path=track.path, - track_intro=track.intro - ) - self.preview_manager.play() - self.show_status_message( - f"Preview: {track.title} / {track.artist} (row {track_info.row_number})", - 0 + track = repository.track_by_id(track_info.track_id) + if not track: + raise ApplicationError( + f"musicmuster.preview: unable to retreive track {track_info.track_id=}" ) + self.preview_manager.set_track_info( + track_id=track.track_id, track_path=track.path, track_intro=track.intro or 0 + ) + self.preview_manager.play() + self.show_status_message( + f"Preview: {track.title} / {track.artist} (row {track_info.row_number})", + 0, + ) else: self.preview_manager.stop() self.show_status_message("", 0) @@ -2311,21 +2236,15 @@ class Window(QMainWindow): row_number = self.preview_manager.row_number if not row_number: return - with db.Session() as session: - track = session.get(Tracks, track_id) - if track: - # Save intro as millisends rounded to nearest 0.1 - # second because editor spinbox only resolves to 0.1 - # seconds - intro = round(self.preview_manager.get_playtime() / 100) * 100 - track.intro = intro - session.commit() - self.preview_manager.set_intro(intro) - self.current.base_model.refresh_row(row_number) - roles = [ - Qt.ItemDataRole.DisplayRole, - ] - self.current.base_model.invalidate_row(row_number, roles) + + intro = round(self.preview_manager.get_playtime() / 100) * 100 + repository.set_track_intro(track_id, intro) + self.preview_manager.set_intro(intro) + self.current.base_model.refresh_row(row_number) + roles = [ + Qt.ItemDataRole.DisplayRole, + ] + self.current.base_model.invalidate_row(row_number, roles) def preview_start(self) -> None: """Restart preview""" @@ -2350,19 +2269,16 @@ class Window(QMainWindow): def rename_playlist(self, checked: bool = False) -> None: """ - Rename current playlist + Rename current playlist. checked is passed by menu but not used here """ - with db.Session() as session: - playlist_id = self.current.playlist_id - playlist = session.get(Playlists, playlist_id) - if playlist: - new_name = self.get_playlist_name(session, playlist.name) - if new_name: - playlist.rename(session, new_name) - idx = self.tabBar.currentIndex() - self.tabBar.setTabText(idx, new_name) - session.commit() + playlist = repository.playlist_by_id(self.current.playlist_id) + if playlist: + new_name = self.get_playlist_name(playlist.name) + if new_name: + repository.playlist_rename(playlist.id, new_name) + idx = self.tabBar.currentIndex() + self.tabBar.setTabText(idx, new_name) def return_pressed_in_error(self) -> bool: """ @@ -2386,7 +2302,9 @@ class Window(QMainWindow): # default to NOT playing the next track, else default to # playing it. default_yes: bool = self.track_sequence.current.start_time is not None and ( - (dt.datetime.now() - self.track_sequence.current.start_time).total_seconds() + ( + dt.datetime.now() - self.track_sequence.current.start_time + ).total_seconds() * 1000 > Config.PLAY_NEXT_GUARD_MS ) @@ -2441,9 +2359,12 @@ class Window(QMainWindow): and self.track_sequence.current.resume_marker ): elapsed_ms = ( - self.track_sequence.current.duration * self.track_sequence.current.resume_marker + self.track_sequence.current.duration + * self.track_sequence.current.resume_marker + ) + self.track_sequence.current.start_time -= dt.timedelta( + milliseconds=elapsed_ms ) - self.track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms) def search_playlist(self, checked: bool = False) -> None: """Show text box to search playlist""" @@ -2496,12 +2417,11 @@ class Window(QMainWindow): def set_main_window_size(self) -> None: """Set size of window from database""" - with db.Session() as session: - x = Settings.get_setting(session, "mainwindow_x").f_int or 100 - y = Settings.get_setting(session, "mainwindow_y").f_int or 100 - width = Settings.get_setting(session, "mainwindow_width").f_int or 100 - height = Settings.get_setting(session, "mainwindow_height").f_int or 100 - self.setGeometry(x, y, width, height) + x = repository.get_setting("mainwindow_x") or 100 + y = repository.get_setting("mainwindow_y") or 100 + width = repository.get_setting("mainwindow_width") or 100 + height = repository.get_setting("mainwindow_height") or 100 + self.setGeometry(x, y, width, height) @log_call def set_selected_track_next(self, checked: bool = False) -> None: @@ -2767,7 +2687,8 @@ class Window(QMainWindow): if ( self.track_sequence.current and self.track_sequence.next - and self.track_sequence.current.playlist_id == self.track_sequence.next.playlist_id + and self.track_sequence.current.playlist_id + == self.track_sequence.next.playlist_id ): set_next = False @@ -2831,12 +2752,10 @@ if __name__ == "__main__": # Run as required if args.check_db: log.debug("Checking database") - with db.Session() as session: - check_db(session) + check_db() elif args.update_bitrates: log.debug("Update bitrates") - with db.Session() as session: - update_bitrates(session) + update_bitrates() else: app = QApplication(sys.argv) try: diff --git a/app/querylistmodel.py b/app/querylistmodel.py index 90567ad..974ca15 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -21,7 +21,6 @@ from PyQt6.QtGui import ( ) # Third party imports -from sqlalchemy.orm.session import Session # import snoop # type: ignore @@ -39,8 +38,9 @@ from helpers import ( show_warning, ) from log import log -from models import db, Playdates, Tracks +from models import db, Playdates from playlistrow import PlaylistRow +import repository @dataclass @@ -64,7 +64,7 @@ class QuerylistModel(QAbstractTableModel): """ - def __init__(self, session: Session, filter: Filter) -> None: + def __init__(self, filter: Filter) -> None: """ Load query """ @@ -72,7 +72,6 @@ class QuerylistModel(QAbstractTableModel): log.debug(f"QuerylistModel.__init__({filter=})") super().__init__() - self.session = session self.filter = filter self.querylist_rows: dict[int, QueryRow] = {} @@ -230,7 +229,7 @@ class QuerylistModel(QAbstractTableModel): row = 0 try: - results = Tracks.get_filtered_tracks(self.session, self.filter) + results = repository.get_filtered_tracks(self.filter) for result in results: lastplayed = None if hasattr(result, "playdates"): @@ -244,7 +243,7 @@ class QuerylistModel(QAbstractTableModel): lastplayed=lastplayed, path=result.path, title=result.title, - track_id=result.id, + track_id=result.track_id, ) self.querylist_rows[row] = queryrow @@ -275,16 +274,7 @@ class QuerylistModel(QAbstractTableModel): if column != QueryCol.LAST_PLAYED.value: return QVariant() - with db.Session() as session: - track_id = self.querylist_rows[row].track_id - if not track_id: - return QVariant() - playdates = Playdates.last_playdates(session, track_id) - return ( - "
".join( - [ - a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) - for a in reversed(playdates) - ] - ) - ) + track_id = self.querylist_rows[row].track_id + if not track_id: + return QVariant() + return repository.get_last_played_dates(track_id) diff --git a/app/repository.py b/app/repository.py index 6a378bd..0c9839e 100644 --- a/app/repository.py +++ b/app/repository.py @@ -1,4 +1,5 @@ # Standard library imports +import datetime as dt import re # PyQt imports @@ -13,10 +14,17 @@ from sqlalchemy import ( from sqlalchemy.orm import aliased from sqlalchemy.orm.session import Session from sqlalchemy.sql.elements import BinaryExpression, ColumnElement -from classes import ApplicationError, PlaylistRowDTO # App imports -from classes import PlaylistDTO, TrackDTO +from classes import ( + ApplicationError, + Filter, + PlaydatesDTO, + PlaylistDTO, + PlaylistRowDTO, + QueryDTO, + TrackDTO, +) from config import Config from log import log, log_call from models import ( @@ -25,13 +33,14 @@ from models import ( Playdates, PlaylistRows, Playlists, + Queries, Settings, Tracks, ) # Helper functions - +@log_call def _remove_substring_case_insensitive(parent_string: str, substring: str) -> str: """ Remove all instances of substring from parent string, case insensitively @@ -107,6 +116,7 @@ def get_colour(text: str, foreground: bool = False) -> str: return rec.colour +@log_call def remove_colour_substring(text: str) -> str: """ Remove text that identifies the colour to be used if strip_substring is True @@ -118,6 +128,131 @@ def remove_colour_substring(text: str) -> str: # Track functions +@log_call +def _tracks_where( + query: BinaryExpression | ColumnElement[bool], + filter_by_last_played: bool = False, + last_played_before: dt.datetime | None = None, +) -> list[TrackDTO]: + """ + Return tracks selected by query + """ + + # Alibas PlaydatesTable for subquery + LatestPlaydate = aliased(Playdates) + + # Create a 'latest playdate' subquery + latest_playdate_subq = ( + select( + LatestPlaydate.track_id, + func.max(LatestPlaydate.lastplayed).label("lastplayed"), + ) + .group_by(LatestPlaydate.track_id) + .subquery() + ) + if not filter_by_last_played: + query = query.outerjoin( + latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id + ) + else: + # We are filtering by last played. If last_played_before is None, + # we want tracks that have never been played + if last_played_before is None: + query = query.outerjoin(Playdates, Tracks.id == Playdates.track_id).where( + Playdates.id.is_(None) + ) + else: + query = query.join( + latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id + ).where(latest_playdate_subq.c.max_last_played < last_played_before) + pass + + stmt = select( + Tracks.id.label("track_id"), + Tracks.artist, + Tracks.bitrate, + Tracks.duration, + Tracks.fade_at, + Tracks.intro, + Tracks.path, + Tracks.silence_at, + Tracks.start_gap, + Tracks.title, + latest_playdate_subq.c.lastplayed, + ).where(query) + + results: list[TrackDTO] = [] + + with db.Session() as session: + records = session.execute(stmt).all() + for record in records: + dto = TrackDTO( + artist=record.artist, + bitrate=record.bitrate, + duration=record.duration, + fade_at=record.fade_at, + intro=record.intro, + lastplayed=record.lastplayed, + path=record.path, + silence_at=record.silence_at, + start_gap=record.start_gap, + title=record.title, + track_id=record.track_id, + ) + results.append(dto) + + return results + + +def track_by_path(path: str) -> TrackDTO | None: + """ + Return track with passed path or None + """ + + track_list = _tracks_where(Tracks.path.ilike(path)) + if not track_list: + return None + if len(track_list) > 1: + raise ApplicationError(f"Duplicate {path=}") + return track_list[0] + + +def track_by_id(track_id: int) -> TrackDTO | None: + """ + Return track with specified id + """ + + track_list = _tracks_where(Tracks.id == track_id) + if not track_list: + return None + if len(track_list) > 1: + raise ApplicationError(f"Duplicate {track_id=}") + return track_list[0] + + +def tracks_by_artist(filter_str: str) -> list[TrackDTO]: + """ + Return tracks where artist is like filter + """ + + return _tracks_where(Tracks.artist.ilike(f"%{filter_str}%")) + + +def tracks_by_title(filter_str: str) -> list[TrackDTO]: + """ + Return tracks where title is like filter + """ + + return _tracks_where(Tracks.title.ilike(f"%{filter_str}%")) + + +def get_all_tracks() -> list[TrackDTO]: + """Return a list of all tracks""" + + return _tracks_where(Tracks.id > 0) + + +@log_call def add_track_to_header(playlistrow_id: int, track_id: int) -> None: """ Add a track to this (header) row @@ -132,6 +267,7 @@ def add_track_to_header(playlistrow_id: int, track_id: int) -> None: session.commit() +@log_call def create_track(path: str, metadata: dict[str, str | int | float]) -> TrackDTO: """ Create a track db entry from a track path and return the DTO @@ -163,6 +299,7 @@ def create_track(path: str, metadata: dict[str, str | int | float]) -> TrackDTO: return new_track +@log_call def update_track( path: str, track_id: int, metadata: dict[str, str | int | float] ) -> TrackDTO: @@ -192,183 +329,208 @@ def update_track( return updated_track -def get_all_tracks() -> list[TrackDTO]: - """Return a list of all tracks""" - - return _tracks_where(Tracks.id > 0) - - -def track_by_id(track_id: int) -> TrackDTO | None: +@log_call +def get_filtered_tracks(filter: Filter) -> list[TrackDTO]: """ - Return track with specified id + Return tracks matching filter """ - # Alias PlaydatesTable for subquery - LatestPlaydate = aliased(Playdates) + # Create a base query + query = Tracks.id > 0 - # Subquery: latest playdate for each track - latest_playdate_subq = ( - select( - LatestPlaydate.track_id, - func.max(LatestPlaydate.lastplayed).label("lastplayed"), - ) - .group_by(LatestPlaydate.track_id) - .subquery() - ) + # Path specification + if filter.path: + if filter.path_type == "contains": + query = query.where(Tracks.path.ilike(f"%{filter.path}%")) + elif filter.path_type == "excluding": + query = query.where(Tracks.path.notilike(f"%{filter.path}%")) + else: + raise ApplicationError(f"Can't process filter path ({filter=})") - stmt = ( - select( - Tracks.id.label("track_id"), - Tracks.artist, - Tracks.bitrate, - Tracks.duration, - Tracks.fade_at, - Tracks.intro, - Tracks.path, - Tracks.silence_at, - Tracks.start_gap, - Tracks.title, - latest_playdate_subq.c.lastplayed, + # Duration specification + seconds_duration = filter.duration_number + if filter.duration_unit == Config.FILTER_DURATION_MINUTES: + seconds_duration *= 60 + elif filter.duration_unit != Config.FILTER_DURATION_SECONDS: + raise ApplicationError(f"Can't process filter duration ({filter=})") + + if filter.duration_type == Config.FILTER_DURATION_LONGER: + query = query.where(Tracks.duration >= seconds_duration) + elif filter.duration_unit == Config.FILTER_DURATION_SHORTER: + query = query.where(Tracks.duration <= seconds_duration) + else: + raise ApplicationError(f"Can't process filter duration type ({filter=})") + + # Process comparator + if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_ANYTIME: + return _tracks_where(query, filter_by_last_played=False) + + elif filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER: + return _tracks_where(query, filter_by_last_played=True, last_played_before=None) + else: + # Last played specification + now = dt.datetime.now() + # Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME + before = now + # If not ANYTIME, set 'before' appropriates + if filter.last_played_unit == Config.FILTER_PLAYED_DAYS: + before = now - dt.timedelta(days=filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS: + before = now - dt.timedelta(days=7 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS: + before = now - dt.timedelta(days=30 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS: + before = now - dt.timedelta(days=365 * filter.last_played_number) + else: + raise ApplicationError("Can't determine last played criteria") + + return _tracks_where( + query, filter_by_last_played=True, last_played_before=before ) - .outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id) - .where(Tracks.id == track_id) - ) + + +def set_track_intro(track_id: int, intro: int) -> None: + """ + Set track intro time + """ with db.Session() as session: - record = session.execute(stmt).one_or_none() - if not record: - return None - - dto = TrackDTO( - artist=record.artist, - bitrate=record.bitrate, - duration=record.duration, - fade_at=record.fade_at, - intro=record.intro, - lastplayed=record.lastplayed, - path=record.path, - silence_at=record.silence_at, - start_gap=record.start_gap, - title=record.title, - track_id=record.track_id, + session.execute( + update(Tracks) + .where(Tracks.id == track_id) + .values(intro=intro) ) - return dto + session.commit() -def _tracks_where(where: BinaryExpression | ColumnElement[bool]) -> list[TrackDTO]: +# Playlist functions +@log_call +def _playlists_where( + query: BinaryExpression | ColumnElement[bool], +) -> list[PlaylistDTO]: """ - Return tracks selected by where + Return playlists selected by query """ - # Alias PlaydatesTable for subquery - LatestPlaydate = aliased(Playdates) + stmt = select( + Playlists.favourite, + Playlists.is_template, + Playlists.id.label("playlist_id"), + Playlists.name, + Playlists.open, + ).where(query) - # Subquery: latest playdate for each track - latest_playdate_subq = ( - select( - LatestPlaydate.track_id, - func.max(LatestPlaydate.lastplayed).label("lastplayed"), - ) - .group_by(LatestPlaydate.track_id) - .subquery() - ) - - stmt = ( - select( - Tracks.id.label("track_id"), - Tracks.artist, - Tracks.bitrate, - Tracks.duration, - Tracks.fade_at, - Tracks.intro, - Tracks.path, - Tracks.silence_at, - Tracks.start_gap, - Tracks.title, - latest_playdate_subq.c.lastplayed, - ) - .outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id) - .where(where) - ) - - results: list[TrackDTO] = [] + results: list[PlaylistDTO] = [] with db.Session() as session: records = session.execute(stmt).all() for record in records: - dto = TrackDTO( - artist=record.artist, - bitrate=record.bitrate, - duration=record.duration, - fade_at=record.fade_at, - intro=record.intro, - lastplayed=record.lastplayed, - path=record.path, - silence_at=record.silence_at, - start_gap=record.start_gap, - title=record.title, - track_id=record.track_id, + dto = PlaylistDTO( + favourite=record.favourite, + is_template=record.is_template, + playlist_id=record.playlist_id, + name=record.name, + open=record.open, ) results.append(dto) return results -def track_with_path(path: str) -> bool: +@log_call +def playlist_by_id(playlist_id: int) -> PlaylistDTO | None: """ - Return True if a track with passed path exists, else False + Return playlist with specified id + """ + + playlist_list = _playlists_where(Playlists.id == playlist_id) + if not playlist_list: + return None + if len(playlist_list) > 1: + raise ApplicationError(f"Duplicate {playlist_id=}") + return playlist_list[0] + + +def playlists_closed() -> list[PlaylistDTO]: + """ + Return a list of closed playlists + """ + + return _playlists_where(Playlists.open.is_(False)) + + +def playlists_open() -> list[PlaylistDTO]: + """ + Return a list of open playlists + """ + + return _playlists_where(Playlists.open.is_(True)) + + +def playlists_template_by_id(playlist_id: int) -> PlaylistDTO | None: + """ + Return a list of closed playlists + """ + + playlist_list = _playlists_where( + Playlists.playlist_id == playlist_id, Playlists.is_template.is_(True) + ) + if not playlist_list: + return None + if len(playlist_list) > 1: + raise ApplicationError(f"Duplicate {playlist_id=}") + return playlist_list[0] + + +def playlists_templates() -> list[PlaylistDTO]: + """ + Return a list of playlist templates + """ + + return _playlists_where(Playlists.is_template.is_(True)) + + +def get_all_playlists(): + """Return all playlists""" + + return _playlists_where(Playlists.id > 0) + + +def delete_playlist(playlist_id: int) -> None: + """Delete playlist""" + + with db.Session() as session: + query = session.get(Playlists, playlist_id) + session.delete(query) + session.commit() + + +def save_as_template(playlist_id: int, template_name: str) -> None: + """ + Save playlist as templated + """ + + new_template = create_playlist(template_name, 0, as_template=True) + + copy_playlist(playlist_id, new_template.id) + + +def playlist_rename(playlist_id: int, new_name: str) -> None: + """ + Rename playlist """ with db.Session() as session: - track = ( - session.execute(select(Tracks).where(Tracks.path == path)) - .scalars() - .one_or_none() + session.execute( + update(Playlists) + .where(Playlists.id == playlist_id) + .values(name=new_name) ) - return track is not None + session.commit() -def tracks_like_artist(filter_str: str) -> list[TrackDTO]: - """ - Return tracks where artist is like filter - """ - - return _tracks_where(Tracks.artist.ilike(f"%{filter_str}%")) - - -def tracks_like_title(filter_str: str) -> list[TrackDTO]: - """ - Return tracks where title is like filter - """ - - return _tracks_where(Tracks.title.ilike(f"%{filter_str}%")) - - -def get_last_played_dates(track_id: int, limit: int = 5) -> str: - """ - Return the most recent 'limit' dates that this track has been played - as a text list - """ - - with db.Session() as session: - playdates = session.scalars( - Playdates.select() - .where(Playdates.track_id == track_id) - .order_by(Playdates.lastplayed.desc()) - .limit(limit) - ).all() - - return "
".join( - [ - a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) - for a in playdates - ] - ) - - -# Playlist functions def _check_playlist_integrity( session: Session, playlist_id: int, fix: bool = False ) -> None: @@ -401,6 +563,20 @@ def _check_playlist_integrity( raise ApplicationError(msg) +@log_call +def playlist_mark_status(playlist_id: int, open: bool) -> None: + """Mark playlist as open or closed""" + + with db.Session() as session: + session.execute( + update(Playlists) + .where(Playlists.id == playlist_id) + .values(open=open) + ) + + session.commit() + + @log_call def _shift_rows( session: Session, playlist_id: int, starting_row: int, shift_by: int @@ -512,15 +688,7 @@ def move_rows( _check_playlist_integrity(session, to_playlist_id, fix=False) -def update_playdates(track_id: int) -> None: - """ - Update playdates for passed track - """ - - with db.Session() as session: - _ = Playdates(session, track_id) - - +@log_call def update_row_numbers( playlist_id: int, id_to_row_number: list[dict[int, int]] ) -> None: @@ -537,7 +705,8 @@ def update_row_numbers( _check_playlist_integrity(session, playlist_id, fix=False) -def create_playlist(name: str, template_id: int) -> PlaylistDTO: +@log_call +def create_playlist(name: str, template_id: int, as_template: bool = False) -> PlaylistDTO: """ Create playlist and return DTO. """ @@ -545,11 +714,15 @@ def create_playlist(name: str, template_id: int) -> PlaylistDTO: with db.Session() as session: try: playlist = Playlists(session, name, template_id) + playlist.is_template = as_template playlist_id = playlist.id session.commit() except Exception: raise ApplicationError("Can't create Playlist") + if template_id != 0: + copy_playlist(template_id, playlist_id) + new_playlist = playlist_by_id(playlist_id) if not new_playlist: raise ApplicationError("Can't retrieve new Playlist") @@ -557,6 +730,7 @@ def create_playlist(name: str, template_id: int) -> PlaylistDTO: return new_playlist +@log_call def get_playlist_row(playlistrow_id: int) -> PlaylistRowDTO | None: """ Return specific row DTO @@ -746,6 +920,40 @@ def get_playlist_rows( return dto_list +def copy_playlist(src_id: int, dst_id: int) -> None: + """Copy playlist entries""" + + with db.Session() as session: + src_rows = session.scalars( + select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id) + ).all() + + for plr in src_rows: + PlaylistRows( + session=session, + playlist_id=dst_id, + row_number=plr.row_number, + note=plr.note, + track_id=plr.track_id, + ) + + +def playlist_row_count(playlist_id: int) -> int: + """ + Return number of rows in playlist + """ + + with db.Session() as session: + count = session.scalar( + select(func.count()) + .select_from(PlaylistRows) + .where(PlaylistRows.playlist_id == playlist_id) + ) + + return count + + +@log_call def insert_row( playlist_id: int, row_number: int, track_id: int | None, note: str ) -> PlaylistRowDTO: @@ -825,33 +1033,220 @@ def remove_rows(playlist_id: int, row_numbers: list[int]) -> None: session.commit() -def playlist_by_id(playlist_id: int) -> PlaylistDTO | None: +@log_call +def update_template_favourite(template_id: int, favourite: bool) -> None: + """Update template favourite""" + + with db.Session() as session: + session.execute( + update(Playlists) + .where(Playlists.id == template_id) + .values(favourite=favourite) + ) + session.commit() + + +@log_call +def playlist_save_tabs(playlist_id_to_tab: dict[int, int]) -> None: """ - Return playlist with specified id + Save the tab numbers of the open playlists. + """ + + with db.Session() as session: + # Clear all existing tab numbers + session.execute( + update(Playlists) + .where(Playlists.id.in_(playlist_id_to_tab.keys())) + .values(tab=None) + ) + for (playlist_id, tab) in playlist_id_to_tab.items(): + session.execute( + update(Playlists) + .where(Playlists.id == playlist_id) + .values(tab=tab) + ) + session.commit() + + +# Playdates +@log_call +def get_last_played_dates(track_id: int, limit: int = 5) -> str: + """ + Return the most recent 'limit' dates that this track has been played + as a text list + """ + + with db.Session() as session: + playdates = session.scalars( + Playdates.select() + .where(Playdates.track_id == track_id) + .order_by(Playdates.lastplayed.desc()) + .limit(limit) + ).all() + + return "
".join( + [ + a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) + for a in playdates + ] + ) + + +def update_playdates(track_id: int) -> None: + """ + Update playdates for passed track + """ + + with db.Session() as session: + _ = Playdates(session, track_id) + + +def playdates_between_dates( + start: dt.datetime, end: dt.datetime | None = None +) -> list[PlaydatesDTO]: + """ + Return a list of PlaydateDTO objects from between times (until now if end is None) + """ + + if end is None: + end = dt.datetime.now() + + stmt = select( + Playdates.id.label("playdate_id"), + Playdates.lastplayed, + Playdates.track_id, + Playdates.track, + ).where( + Playdates.lastplayed >= start, + Playdates.lastplayed <= end + ) + + results: list[PlaydatesDTO] = [] + + with db.Session() as session: + records = session.execute(stmt).all() + for record in records: + dto = PlaydatesDTO( + playdate_id=record.playdate_id, + lastplayed=record.lastplayed, + track_id=record.track_id, + artist=record.track.artist, + bitrate=record.track.bitrate, + duration=record.track.duration, + fade_at=record.track.fade_at, + intro=record.track.intro, + path=record.track.path, + silence_at=record.track.silence_at, + start_gap=record.track.start_gap, + title=record.track.title, + ) + results.append(dto) + + return results + + +# Queries +@log_call +def _queries_where( + query: BinaryExpression | ColumnElement[bool], +) -> list[QueryDTO]: + """ + Return queries selected by query """ stmt = select( - Playlists.id.label("playlist_id"), - Playlists.name, - Playlists.favourite, - Playlists.is_template, - Playlists.open, - ).where(Playlists.id == playlist_id) + Queries.id.label("query_id"), Queries.name, Queries.favourite, Queries.filter + ).where(query) + + results: list[QueryDTO] = [] with db.Session() as session: - record = session.execute(stmt).one_or_none() - if not record: - return None + records = session.execute(stmt).one_or_none() + for record in records: + dto = QueryDTO( + favourite=record.favourite, + filter=record.filter, + name=record.name, + query_id=record.query_id, + ) + results.append(dto) - dto = PlaylistDTO( - name=record.name, - playlist_id=record.playlist_id, - favourite=record.favourite, - is_template=record.is_template, - open=record.open, + return results + + +def get_all_queries(favourites_only: bool = False) -> list[QueryDTO]: + """Return a list of all queries""" + + query = Queries.id > 0 + return _queries_where(query) + + +def query_by_id(query_id: int) -> QueryDTO | None: + """Return query""" + + query_list = _queries_where(Queries.id == query_id) + if not query_list: + return None + if len(query_list) > 1: + raise ApplicationError(f"Duplicate {query_id=}") + return query_list[0] + + +def update_query_filter(query_id: int, filter: Filter) -> None: + """Update query filter""" + + with db.Session() as session: + session.execute( + update(Queries).where(Queries.id == query_id).values(filter=filter) ) + session.commit() - return dto + +def delete_query(query_id: int) -> None: + """Delete query""" + + with db.Session() as session: + query = session.get(Queries, query_id) + session.delete(query) + session.commit() + + +def update_query_name(query_id: int, name: str) -> None: + """Update query name""" + + with db.Session() as session: + session.execute(update(Queries).where(Queries.id == query_id).values(name=name)) + session.commit() + + +def update_query_favourite(query_id: int, favourite: bool) -> None: + """Update query favourite""" + + with db.Session() as session: + session.execute( + update(Queries).where(Queries.id == query_id).values(favourite=favourite) + ) + session.commit() + + +def create_query(name: str, filter: Filter) -> QueryDTO: + """ + Create a query and return the DTO + """ + + with db.Session() as session: + try: + query = Queries(session=session, name=name, filter=filter) + query_id = query.id + session.commit() + except Exception: + raise ApplicationError("Can't create Query") + + new_query = query_by_id(query_id) + if not new_query: + raise ApplicationError("Unable to create new query") + + return new_query # Misc @@ -889,3 +1284,14 @@ def set_setting(name: str, value: int) -> None: raise ApplicationError("Can't create Settings record") record.f_int = value session.commit() + + +def get_db_name() -> str: + """Return database name""" + + with db.Session() as session: + if session.bind: + dbname = session.bind.engine.url.database + return dbname + return Config.DB_NOT_FOUND + diff --git a/app/utilities.py b/app/utilities.py index 8c42a8a..9d297fd 100755 --- a/app/utilities.py +++ b/app/utilities.py @@ -5,7 +5,6 @@ import os # PyQt imports # Third party imports -from sqlalchemy.orm.session import Session # App imports from config import Config @@ -13,10 +12,10 @@ from helpers import ( get_tags, ) from log import log -from models import Tracks +import repository -def check_db(session: Session) -> None: +def check_db() -> None: """ Database consistency check. @@ -27,7 +26,7 @@ def check_db(session: Session) -> None: Check all paths in database exist """ - db_paths = set([a.path for a in Tracks.get_all(session)]) + db_paths = set([a.path for a in repository.get_all_tracks()]) os_paths_list = [] for root, _dirs, files in os.walk(Config.ROOT): @@ -52,7 +51,7 @@ def check_db(session: Session) -> None: missing_file_count += 1 - track = Tracks.get_by_path(session, path) + track = repository.track_by_path(path) if not track: # This shouldn't happen as we're looking for paths in # database that aren't in filesystem, but just in case... @@ -74,7 +73,7 @@ def check_db(session: Session) -> None: for t in paths_not_found: print( f""" - Track ID: {t.id} + Track ID: {t.track_id} Path: {t.path} Title: {t.title} Artist: {t.artist} @@ -84,14 +83,15 @@ def check_db(session: Session) -> None: print("There were more paths than listed that were not found") -def update_bitrates(session: Session) -> None: +def update_bitrates() -> None: """ Update bitrates on all tracks in database """ - for track in Tracks.get_all(session): + for track in repository.get_all_tracks(): try: t = get_tags(track.path) + # TODO this won't persist as we're updating DTO track.bitrate = t.bitrate except FileNotFoundError: continue diff --git a/tests/test_repository.py b/tests/test_repository.py index a210f53..e94999b 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -131,10 +131,10 @@ class MyTestCase(unittest.TestCase): _ = repository.create_track(self.isa_path, metadata) metadata = get_all_track_metadata(self.mom_path) _ = repository.create_track(self.mom_path, metadata) - result_isa = repository.tracks_like_title(self.isa_title) + result_isa = repository.tracks_by_title(self.isa_title) assert len(result_isa) == 1 assert result_isa[0].title == self.isa_title - result_mom = repository.tracks_like_title(self.mom_title) + result_mom = repository.tracks_by_title(self.mom_title) assert len(result_mom) == 1 assert result_mom[0].title == self.mom_title diff --git a/tests/test_ui.py b/tests/test_ui.py index 508c700..0cfded6 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -132,9 +132,8 @@ class MyTestCase(unittest.TestCase): Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata") - with db.Session() as session: - utilities.check_db(session) - utilities.update_bitrates(session) + utilities.check_db() + utilities.update_bitrates() # def test_meta_all_clear(qtbot, session):