From caecf63f6a6076a8cf21c2b26d8e009b6e1117b3 Mon Sep 17 00:00:00 2001 From: "billy@pom" Date: Sun, 15 Mar 2026 22:39:43 -0400 Subject: [PATCH] newly added playlist builtin --- components/MusicTable.py | 230 ++++++++++++++++++--------------- components/PlaylistsPane.py | 15 ++- main.py | 120 ++++++++++------- utils/__init__.py | 2 +- utils/export_playlist_by_id.py | 38 +++--- utils/get_reorganize_vars.py | 79 ++++++++++- 6 files changed, 316 insertions(+), 168 deletions(-) diff --git a/components/MusicTable.py b/components/MusicTable.py index 04b9da8..ae5a014 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -46,16 +46,18 @@ from utils import ( id3_remap, get_tags, set_tag, - Worker + Worker, ) from subprocess import Popen from logging import debug, error -import os -import shutil -import typing from pathlib import Path from appdirs import user_config_dir from configparser import ConfigParser +import os +import shutil +import typing +import datetime + class MusicTable(QTableView): playlistStatsSignal: pyqtSignal = pyqtSignal(str) @@ -83,7 +85,7 @@ class MusicTable(QTableView): / "config.ini" ) _ = self.config.read(self.cfg_file) - self.sort_config: str = self.config.get('table', 'sort_order') + self.sort_config: str = self.config.get("table", "sort_order") self.column_sort_order: list[tuple[str, Qt.SortOrder | None]] = [] font: QFont = QFont() @@ -135,8 +137,12 @@ class MusicTable(QTableView): self.horizontal_header.setSectionResizeMode(QHeaderView.Interactive) # self.horizontal_header.sortIndicatorChanged.connect(self.on_user_sort_change) self.horizontal_header.sectionClicked.connect(self.on_header_clicked) - self.horizontal_header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.horizontal_header.customContextMenuRequested.connect(self.show_header_context_menu) + self.horizontal_header.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self.horizontal_header.customContextMenuRequested.connect( + self.show_header_context_menu + ) # dumb vertical estupido self.vertical_header: QHeaderView = self.verticalHeader() assert self.vertical_header is not None @@ -173,7 +179,6 @@ class MusicTable(QTableView): """ self.focusLeaveSignal.emit() - def resizeEvent(self, e: typing.Optional[QResizeEvent]) -> None: """ Do something when the QTableView is resized @@ -194,9 +199,11 @@ class MusicTable(QTableView): super().showEvent(a0) widths = [] for _ in self.saved_column_ratios: - widths.append('0.001') + widths.append("0.001") self.load_header_widths(widths) - QTimer.singleShot(0, lambda: self.load_header_widths(ratios=self.saved_column_ratios)) + QTimer.singleShot( + 0, lambda: self.load_header_widths(ratios=self.saved_column_ratios) + ) def paintEvent(self, e): """Override paint event to highlight the current cell""" @@ -223,7 +230,6 @@ class MusicTable(QTableView): menu = QMenu(self) menu.setFont(font) - # OLD CODE but i keep cus i sais so # add_to_playlist_action = QAction("Add to playlist", self) # _ = add_to_playlist_action.triggered.connect(self.open_add_to_playlist_window) @@ -236,7 +242,8 @@ class MusicTable(QTableView): for playlist_id, playlist_name in playlists: action = QAction(playlist_name, add_to_playlist_menu) action.triggered.connect( - lambda checked=False, pid=playlist_id: self.add_selected_songs_to_playlist(pid) + lambda checked=False, + pid=playlist_id: self.add_selected_songs_to_playlist(pid) ) add_to_playlist_menu.addAction(action) menu.addMenu(add_to_playlist_menu) @@ -254,8 +261,7 @@ class MusicTable(QTableView): _ = jump_to_current_song_action.triggered.connect(self.jump_to_current_song) menu.addAction(jump_to_current_song_action) # open in file explorer - open_containing_folder_action = QAction( - "Open in system file manager", self) + open_containing_folder_action = QAction("Open in system file manager", self) _ = open_containing_folder_action.triggered.connect(self.open_directory) menu.addAction(open_containing_folder_action) # view id3 tags (debug) @@ -309,10 +315,12 @@ class MusicTable(QTableView): if directories: worker = Worker(self.get_audio_files_recursively, directories) _ = worker.signals.signal_progress.connect(self.handle_progress) - _ = worker.signals.signal_result.connect(self.on_get_audio_files_recursively_finished) + _ = worker.signals.signal_result.connect( + self.on_get_audio_files_recursively_finished + ) _ = worker.signals.signal_finished.connect(self.load_music_table) if self.qapp: - threadpool = self.qapp.threadpool # type: ignore + threadpool = self.qapp.threadpool # type: ignore threadpool.start(worker) if files: self.add_files_to_library(files) @@ -372,7 +380,6 @@ class MusicTable(QTableView): else: # Default behavior super().keyPressEvent(e) - # ____________________ # | | # | | @@ -387,7 +394,6 @@ class MusicTable(QTableView): self.find_current_and_selected_bits() self.jump_to_selected_song() - def on_cell_clicked(self, index): """ When a cell is clicked, do some stuff :) @@ -412,18 +418,15 @@ class MusicTable(QTableView): edited_column_name: str = self.headers.db_list[topLeft.column()] # debug(f"on_cell_data_changed | edited column name: {edited_column_name}") response = set_tag( - filepath=filepath, - db_column=edited_column_name, - value=user_input_data + filepath=filepath, db_column=edited_column_name, value=user_input_data ) if response: # Update the library with new metadata _ = update_song_in_database(song_id, edited_column_name, user_input_data) else: - error('ERROR: response failed') + error("ERROR: response failed") return - def on_get_audio_files_recursively_finished(self, result: list[str]): """file search completion handler""" if result: @@ -448,8 +451,7 @@ class MusicTable(QTableView): except IndexError: pass except Exception as e: - debug( - f"on_add_files_to_database_finished() | Something went wrong: {e}") + debug(f"on_add_files_to_database_finished() | Something went wrong: {e}") # ____________________ # | | @@ -458,7 +460,6 @@ class MusicTable(QTableView): # | | # |____________________| - def handle_progress(self, data: object): """Emits data to main""" self.handleProgressSignal.emit(data) @@ -468,8 +469,8 @@ class MusicTable(QTableView): Show context menu on right-click in the horizontal header. """ # NOTE: - # Jus a side note, creating a nemu on the heap every time the user right clicks - # is going to eat up a lot of memory in the long term. It's much more efficient + # Jus a side note, creating a nemu on the heap every time the user right clicks + # is going to eat up a lot of memory in the long term. It's much more efficient # to create it once and call popup on it when needed # NOTE: @@ -484,17 +485,13 @@ class MusicTable(QTableView): sort_asc_action = QAction("Sort ASC", self) sort_asc_action.triggered.connect( lambda checked=False: self.mark_column_sort( - logical_index, - column_name, - Qt.SortOrder.AscendingOrder + logical_index, column_name, Qt.SortOrder.AscendingOrder ) ) sort_desc_action = QAction("Sort DESC", self) sort_desc_action.triggered.connect( lambda checked=False: self.mark_column_sort( - logical_index, - column_name, - Qt.SortOrder.DescendingOrder + logical_index, column_name, Qt.SortOrder.DescendingOrder ) ) menu.addAction(clear_action) @@ -506,7 +503,7 @@ class MusicTable(QTableView): def mark_column_sort(self, logical_index, column_name, sort_order): """ - Marks a column as sorted in a particular direction + Marks a column as sorted in a particular direction - logical_index: called from `show_header_context_menu()` - column_name: database column name - sort_order: 0 = asc, 1 = desc @@ -515,7 +512,9 @@ class MusicTable(QTableView): # write field name and the sort direction into config # then run sort by logical fields or something to sort the config as is - debug(f'mark_column_sort(logical_index={logical_index}, column_name={column_name}, sort_order={sort_order})') + debug( + f"mark_column_sort(logical_index={logical_index}, column_name={column_name}, sort_order={sort_order})" + ) raw = self.sort_config # Clear the sort order list self.column_sort_order = [] @@ -538,12 +537,13 @@ class MusicTable(QTableView): else: direction = None self.column_sort_order.append((field, direction)) - debug(f'- sort list updated (field, direction): {self.column_sort_order}') - self.sort_config = ",".join(f'{field}:{order}' for field, order in self.column_sort_order) + debug(f"- sort list updated (field, direction): {self.column_sort_order}") + self.sort_config = ",".join( + f"{field}:{order}" for field, order in self.column_sort_order + ) # Re-apply the updated sort order self.sort_by_logical_fields() - def clear_all_sorts(self): """ Clears all stored sort orders and refreshes the table view. @@ -551,7 +551,6 @@ class MusicTable(QTableView): # Clear sort visually self.horizontal_header.setSortIndicator(-1, Qt.SortOrder.AscendingOrder) - def find_qmodel_index_by_value(self, model, column: int, value) -> QModelIndex: """ Returns the QModelIndex of a specified model, based on column number & field value @@ -563,7 +562,7 @@ class MusicTable(QTableView): return QModelIndex() if column > model.columnCount(): - return model.index(0,0) + return model.index(0, 0) if value: for row in range(model.rowCount()): @@ -571,8 +570,7 @@ class MusicTable(QTableView): if index.data() == value: return index - return model.index(0,0) - + return model.index(0, 0) def get_current_header_width_ratios(self) -> list[str]: """ @@ -581,7 +579,7 @@ class MusicTable(QTableView): total_table_width = self.size().width() column_ratios = [] for i in range(self.model2.columnCount()): - # for i in range(self.model2.columnCount() - 1): + # for i in range(self.model2.columnCount() - 1): # debug(f'column count: {self.model2.columnCount()}') column_width = self.columnWidth(i) ratio = column_width / total_table_width @@ -589,12 +587,11 @@ class MusicTable(QTableView): # debug(f'get_current_header_width_ratios = {column_ratios}') return column_ratios - def save_header_ratios(self): """ Saves the current header widths to memory and file, as ratios """ - # WARNING: DOES NOT WORK and is not used. + # WARNING: DOES NOT WORK and is not used. # the same functionality is implemented in main.py closeEvent() self.saved_column_ratios = self.get_current_header_width_ratios() column_ratios_as_string = ",".join(self.saved_column_ratios) @@ -607,7 +604,6 @@ class MusicTable(QTableView): debug(f"wtf man {e}") # debug(f"Saved column ratios: {self.saved_column_ratios}") - def load_header_widths(self, ratios: list[str] | None = None): """ Loads the header widths, based on saved ratios @@ -623,15 +619,13 @@ class MusicTable(QTableView): for ratio in column_ratios: column_widths.append(float(ratio) * total_table_width) if isinstance(column_widths, list): - for i in range(self.model2.columnCount()-1): + for i in range(self.model2.columnCount() - 1): # load all except the last ratio - bcus it gets autostretched self.setColumnWidth(i, int(column_widths[i])) - def set_qmodel_index(self, index: QModelIndex): self.current_song_qmodel_index = index - def play_selected_audio_file(self): """ Sets the current song filepath @@ -641,7 +635,6 @@ class MusicTable(QTableView): self.set_current_song_filepath() self.playSignal.emit(self.current_song_filepath) - def add_files_to_library(self, files: list[str]) -> None: """ Spawns a worker thread - adds a list of filepaths to the library @@ -651,20 +644,18 @@ class MusicTable(QTableView): """ # debug('add_files_to_library()') worker = Worker(add_files_to_database, files, None) - _ = worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore + _ = worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore _ = worker.signals.signal_result.connect(self.on_add_files_to_database_finished) _ = worker.signals.signal_finished.connect(self.load_music_table) if self.qapp: - threadpool = self.qapp.threadpool # type: ignore + threadpool = self.qapp.threadpool # type: ignore threadpool.start(worker) else: error("Application window could not be found") - def open_add_to_playlist_window(self): """Opens a playlist choice menu and adds the currently selected files to the chosen playlist""" - playlist_choice_window = AddToPlaylistWindow( - self.get_selected_songs_db_ids()) + playlist_choice_window = AddToPlaylistWindow(self.get_selected_songs_db_ids()) playlist_choice_window.exec_() def add_selected_songs_to_playlist(self, playlist_id): @@ -680,7 +671,6 @@ class MusicTable(QTableView): f"AddToPlaylistWindow.py save() | could not insert song into playlist: {e}" ) - def delete_songs(self): """Asks to delete the currently selected songs from the db and music table (not the filesystem)""" # NOTE: provide extra questionbox option? @@ -695,11 +685,15 @@ class MusicTable(QTableView): ) reply = question_dialog.execute() if reply: - worker = Worker(batch_delete_filepaths_from_playlist, selected_filepaths, self.selected_playlist_id) - worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore + worker = Worker( + batch_delete_filepaths_from_playlist, + selected_filepaths, + self.selected_playlist_id, + ) + worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore worker.signals.signal_finished.connect(self.delete_selected_row_indices) if self.qapp: - threadpool = self.qapp.threadpool # type: ignore + threadpool = self.qapp.threadpool # type: ignore threadpool.start(worker) else: question_dialog = QuestionBoxDetails( @@ -709,11 +703,13 @@ class MusicTable(QTableView): ) reply = question_dialog.execute() if reply: - worker = Worker(batch_delete_filepaths_from_database, selected_filepaths) - worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore + worker = Worker( + batch_delete_filepaths_from_database, selected_filepaths + ) + worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore worker.signals.signal_finished.connect(self.delete_selected_row_indices) if self.qapp: - threadpool = self.qapp.threadpool # type: ignore + threadpool = self.qapp.threadpool # type: ignore threadpool.start(worker) # def delete_selected_row_indices(self): @@ -752,7 +748,9 @@ class MusicTable(QTableView): """Opens a form with metadata from the selected audio files""" files = self.get_selected_songs_filepaths() song_ids = self.get_selected_songs_db_ids() - window = MetadataWindow(self.refreshMusicTableSignal, self.headers, files, song_ids) + window = MetadataWindow( + self.refreshMusicTableSignal, self.headers, files, song_ids + ) window.refreshMusicTableSignal.connect(self.load_music_table) window.exec_() # Display the preferences window modally @@ -766,16 +764,18 @@ class MusicTable(QTableView): def jump_to_current_song(self): """Moves screen to the currently playing song, then selects the row""" - debug('jump_to_current_song()') + debug("jump_to_current_song()") # get the proxy model index try: proxy_index = self.proxymodel.mapFromSource(self.current_song_qmodel_index) self.scrollTo(proxy_index) self.selectRow(proxy_index.row()) except Exception as e: - debug(f'jump_to_current_song() | {self.current_song_filepath}') - debug(f'jump_to_current_song() | {self.current_song_qmodel_index}') - debug(f'jump_to_current_song() | Could not find current song in current table buffer - {e}') + debug(f"jump_to_current_song() | {self.current_song_filepath}") + debug(f"jump_to_current_song() | {self.current_song_qmodel_index}") + debug( + f"jump_to_current_song() | Could not find current song in current table buffer - {e}" + ) def open_directory(self): """Opens the containing directory of the currently selected song, in the system file manager""" @@ -844,7 +844,7 @@ class MusicTable(QTableView): worker = Worker(self.reorganize_files, filepaths) worker.signals.signal_progress.connect(self.handle_progress) worker.signals.signal_finished.connect(self.load_music_table) - self.qapp.threadpool.start(worker) # type: ignore + self.qapp.threadpool.start(worker) # type: ignore def reorganize_files(self, filepaths, progress_callback=None): """ @@ -864,7 +864,9 @@ class MusicTable(QTableView): # Read file metadata artist, album = get_reorganize_vars(filepath) # Determine the new path that needs to be made - new_path = os.path.join(target_dir, artist, album, os.path.basename(filepath)) + new_path = os.path.join( + target_dir, artist, album, os.path.basename(filepath) + ) # Need to determine if filepath is equal if new_path == filepath: continue @@ -904,10 +906,17 @@ class MusicTable(QTableView): hint: You get a `playlist_id` from the signal emitted from PlaylistsPane as a tuple (1,) """ + # no playlist_id = library + # negative playlist id = builtin + if playlist_id: if self.selected_playlist_id == playlist_id[0]: # Don't reload if we clicked the same item return + self.selected_playlist_id = playlist_id[0] + elif self.selected_playlist_id is None: + self.selected_playlist_id = 0 + self.model2.clear() self.model2.setHorizontalHeaderLabels(self.headers.db_list) fields = ", ".join(self.headers.db_list) @@ -917,18 +926,15 @@ class MusicTable(QTableView): else "" ) params = "" - is_playlist = 0 - if len(playlist_id) > 0: - if playlist_id[0] == 0: - self.selected_playlist_id = 0 - is_playlist = 0 - else: - self.selected_playlist_id = playlist_id[0] - is_playlist = 1 - else: - self.selected_playlist_id = 0 - is_playlist = 0 + # Check if actual playlist or library/builtin playlist + is_playlist = 0 + if self.selected_playlist_id <= 0: # type: ignore + is_playlist = 0 + else: + is_playlist = 1 + + # NOTE: caching gave almost no performance benefit on 20k song library # try: # # Check cache for already loaded QTableView QStandardItemModel # data = self.data_cache[self.selected_playlist_id] @@ -940,7 +946,8 @@ class MusicTable(QTableView): try: with DBA.DBAccess() as db: query = f"SELECT id, { - fields} FROM song JOIN song_playlist sp ON id = sp.song_id WHERE sp.playlist_id = ?" + fields + } FROM song JOIN song_playlist sp ON id = sp.song_id WHERE sp.playlist_id = ?" # fulltext search if self.search_string: # params = 3 * [self.search_string] @@ -949,8 +956,7 @@ class MusicTable(QTableView): query = f"{query} WHERE {search_clause};" else: query = f"{query} AND {search_clause};" - data = db.query( - query, (self.selected_playlist_id, params)) + data = db.query(query, (self.selected_playlist_id, params)) else: data = db.query(query, (self.selected_playlist_id,)) @@ -966,13 +972,21 @@ class MusicTable(QTableView): if self.search_string: params = ["%" + self.search_string + "%"] * 3 if query.find("WHERE") == -1: - query = f"{query} WHERE {search_clause};" + query = f"{query} WHERE {search_clause}" else: - query = f"{query} AND {search_clause};" + query = f"{query} AND {search_clause}" + # builtin playlist: newly added + if self.selected_playlist_id == -1: + newly_added_clause = f"date_added >= '{datetime.date.today() - datetime.timedelta(days=90)}'" + if query.find("WHERE") == -1: + query = f"{query} WHERE {newly_added_clause}" + else: + query = f"{query} AND {newly_added_clause}" data = db.query( query, (params), ) + debug(query) except Exception as e: error(f"load_music_table() | Unhandled exception 2: {e}") return @@ -983,7 +997,9 @@ class MusicTable(QTableView): self.current_playlist_id = self.selected_playlist_id db_name: str = self.config.get("settings", "db").split("/").pop() db_filename = self.config.get("settings", "db") - self.playlistStatsSignal.emit(f"Songs: {self.model2.rowCount()} | {db_name} | {db_filename}") + self.playlistStatsSignal.emit( + f"Songs: {self.model2.rowCount()} | {db_name} | {db_filename}" + ) self.loadMusicTableSignal.emit() self.find_current_and_selected_bits() self.jump_to_current_song() @@ -994,8 +1010,12 @@ class MusicTable(QTableView): might as well get the selected song too i guess? though nothing should be selected when reloading the table data """ search_col_num = self.headers.db_list.index("filepath") - selected_qmodel_index = self.find_qmodel_index_by_value(self.proxymodel, search_col_num, self.selected_song_filepath) - current_qmodel_index = self.find_qmodel_index_by_value(self.proxymodel, search_col_num, self.current_song_filepath) + selected_qmodel_index = self.find_qmodel_index_by_value( + self.proxymodel, search_col_num, self.selected_song_filepath + ) + current_qmodel_index = self.find_qmodel_index_by_value( + self.proxymodel, search_col_num, self.current_song_filepath + ) # Update the 2 proxy QModelIndexes that we track self.set_selected_song_qmodel_index(selected_qmodel_index) self.set_current_song_qmodel_index(current_qmodel_index) @@ -1022,9 +1042,8 @@ class MusicTable(QTableView): item.setData(id, Qt.ItemDataRole.UserRole) self.model2.appendRow(items) - def get_sort_config_by_name(self) -> list[tuple[str, Qt.SortOrder]]: - debug('get_sort_config_by_name()') + debug("get_sort_config_by_name()") sort_config = [] raw_sort = self.sort_config if not raw_sort: @@ -1049,7 +1068,7 @@ class MusicTable(QTableView): """ Returns a list of (column_index, Qt.SortOrder) tuples based on config and headers """ - debug('get_sort_config_by_index()') + debug("get_sort_config_by_index()") sort_config = [] raw_sort = self.sort_config if not raw_sort: @@ -1079,7 +1098,7 @@ class MusicTable(QTableView): """ Sorts the table using logical field names defined in config. """ - debug('sort_by_logical_fields()') + debug("sort_by_logical_fields()") sort_config = self.get_sort_config_by_index() # Sort in reverse order (primary sort last) for col_index, order in reversed(sort_config): @@ -1090,11 +1109,12 @@ class MusicTable(QTableView): else: continue self.sortByColumn(col_index, new_order) - debug(f'- sorted column index {col_index} by {new_order}') + debug(f"- sorted column index {col_index} by {new_order}") self.proxymodel.invalidateFilter() - - def get_audio_files_recursively(self, directories: list[str], progress_callback=None) -> list[str]: + def get_audio_files_recursively( + self, directories: list[str], progress_callback=None + ) -> list[str]: """Scans a directories for files""" extensions = self.config.get("settings", "extensions").split(",") audio_files: list[str] = [] @@ -1139,7 +1159,10 @@ class MusicTable(QTableView): return [] selected_rows = set(index.row() for index in indexes) id_list = [ - self.proxymodel.data(self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole) for row in selected_rows + self.proxymodel.data( + self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole + ) + for row in selected_rows ] return id_list @@ -1163,9 +1186,13 @@ class MusicTable(QTableView): except ValueError: # if the user doesnt have filepath selected as a header, retrieve the file from db row = self.currentIndex().row() - id = self.proxymodel.data(self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole) + id = self.proxymodel.data( + self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole + ) with DBA.DBAccess() as db: - filepath = db.query("SELECT filepath FROM song WHERE id = ?", (id,))[0][0] + filepath = db.query("SELECT filepath FROM song WHERE id = ?", (id,))[0][ + 0 + ] self.selected_song_filepath = filepath def set_current_song_filepath(self, filepath=None) -> None: @@ -1175,7 +1202,9 @@ class MusicTable(QTableView): """ # update the filepath if not filepath: - path = self.current_song_qmodel_index.siblingAtColumn(self.headers.db_list.index("filepath")).data() + path = self.current_song_qmodel_index.siblingAtColumn( + self.headers.db_list.index("filepath") + ).data() self.current_song_filepath: str = path else: self.current_song_filepath = filepath @@ -1234,7 +1263,6 @@ class MusicTable(QTableView): pass - # QT Roles # In Qt, roles are used to specify different aspects or types of data associated with each item in a model. The roles are defined in the Qt.ItemDataRole enum. The three roles you asked about - DisplayRole, EditRole, and UserRole - are particularly important. Let's break them down: diff --git a/components/PlaylistsPane.py b/components/PlaylistsPane.py index 23447ec..17ff2be 100644 --- a/components/PlaylistsPane.py +++ b/components/PlaylistsPane.py @@ -43,7 +43,7 @@ class PlaylistsPane(QTreeWidget): self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.showContextMenu) self.currentItemChanged.connect(self.playlist_clicked) - self.playlist_db_id_choice: int | None = None + self.playlist_db_id_choice: int = 0 font: QFont = QFont() font.setPointSize(14) font.setBold(False) @@ -62,6 +62,8 @@ class PlaylistsPane(QTreeWidget): "SELECT id, name FROM playlist ORDER BY date_created DESC;", () ) # debug(f'PlaylistsPane: | playlists = {playlists}') + branch = PlaylistWidgetItem(self, -1, "newly added") + self._playlists_root.addChild(branch) for playlist in playlists: branch = PlaylistWidgetItem(self, playlist[0], playlist[1]) self._playlists_root.addChild(branch) @@ -71,7 +73,7 @@ class PlaylistsPane(QTreeWidget): def showContextMenu(self, position: QPoint): """Right-click context menu""" menu = QMenu(self) - if self.playlist_db_id_choice is not None: + if self.playlist_db_id_choice > 0: # only allow delete/rename non-root nodes rename_action = QAction("Rename", self) delete_action = QAction("Delete", self) @@ -135,20 +137,23 @@ class PlaylistsPane(QTreeWidget): if self.qapp: with DBA.DBAccess() as db: db.execute( - "DELETE FROM playlist WHERE id = ?;", (self.playlist_db_id_choice,) + "DELETE FROM playlist WHERE id = ?;", + (self.playlist_db_id_choice,), ) # reload worker = Worker(self.reload_playlists) threadpool = self.qapp.threadpool threadpool.start(worker) else: - error_dialog = ErrorDialog("Main application [qapp] not loaded - aborting operation") + error_dialog = ErrorDialog( + "Main application [qapp] not loaded - aborting operation" + ) error_dialog.exec() def playlist_clicked(self, item): """Specific playlist pane index was clicked""" if item == self._playlists_root or item == self._library_root: - self.playlist_db_id_choice = None + self.playlist_db_id_choice = 0 # self.all_songs_selected() self.playlistChoiceSignal.emit(0) elif isinstance(item, PlaylistWidgetItem): diff --git a/main.py b/main.py index ffd274e..0806e49 100644 --- a/main.py +++ b/main.py @@ -7,8 +7,10 @@ import qdarktheme from PyQt5.QtGui import QFontDatabase from PyQt5 import QtCore from subprocess import run + # from pyqtgraph import mkBrush from mutagen.id3 import ID3 + # from mutagen.id3._frames import APIC from configparser import ConfigParser from pathlib import Path @@ -49,7 +51,7 @@ from utils import ( set_album_art, id3_remap, get_album_art, - Worker + Worker, ) from components import ( MediaPlayer, @@ -59,7 +61,7 @@ from components import ( CreatePlaylistWindow, ExportPlaylistWindow, HeaderTags2, - DebugWindow + DebugWindow, ) from utils.export_playlist_by_id import export_playlist_by_id @@ -100,13 +102,17 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): # widget bits self.tableView: MusicTable - self.tableView.saved_column_ratios: list[str] = str(self.config["table"]["column_ratios"]).split(",") # type: ignore + self.tableView.saved_column_ratios: list[str] = str( + self.config["table"]["column_ratios"] + ).split(",") # type: ignore self.album_art_scene: QGraphicsScene = QGraphicsScene() self.player: QMediaPlayer = MediaPlayer() # set index on choose song # index is the model2's row number? i guess? self.probe: QAudioProbe = QAudioProbe() # Gets audio buffer data - self.audio_visualizer: AudioVisualizer = AudioVisualizer(self.player, self.probe, self.PlotWidget) + self.audio_visualizer: AudioVisualizer = AudioVisualizer( + self.player, self.probe, self.PlotWidget + ) self.timer: QTimer = QTimer(parent=self) # for playback slider and such # Button styles @@ -147,10 +153,14 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.albumGraphicsView.setFixedSize(250, 250) # Connections - self.playbackSlider.sliderReleased.connect(lambda: self.player.setPosition(self.playbackSlider.value())) # sliderReleased works better than sliderMoved + self.playbackSlider.sliderReleased.connect( + lambda: self.player.setPosition(self.playbackSlider.value()) + ) # sliderReleased works better than sliderMoved self.volumeSlider.sliderMoved[int].connect(lambda: self.on_volume_changed()) - self.speedSlider.sliderMoved.connect(lambda: self.on_speed_changed(self.speedSlider.value())) - #self.speedSlider.doubleClicked.connect(lambda: self.on_speed_changed(1)) + self.speedSlider.sliderMoved.connect( + lambda: self.on_speed_changed(self.speedSlider.value()) + ) + # self.speedSlider.doubleClicked.connect(lambda: self.on_speed_changed(1)) self.playButton.clicked.connect(self.on_play_clicked) # Click to play/pause self.previousButton.clicked.connect(self.on_prev_clicked) self.nextButton.clicked.connect(self.on_next_clicked) # Click to next song @@ -162,10 +172,9 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): # EDIT MENU self.actionPreferences.triggered.connect(self.open_preferences) # VIEW MENU - self.actionFontListing.triggered.connect(self.open_font_listing) + self.actionFontListing.triggered.connect(self.open_font_listing) QFontDatabase().families() - # QUICK ACTIONS MENU self.actionScanLibraries.triggered.connect(self.scan_libraries) self.actionDeleteDatabase.triggered.connect(self.delete_database) @@ -182,21 +191,30 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.searchLineEdit.textTypedSignal.connect(self.handle_search_box_text) # tableView self.tableView.playSignal.connect(self.play_audio_file) - self.tableView.playPauseSignal.connect(self.on_play_clicked) # Spacebar toggle play/pause signal + self.tableView.playPauseSignal.connect( + self.on_play_clicked + ) # Spacebar toggle play/pause signal self.tableView.handleProgressSignal.connect(self.handle_progress) self.tableView.searchBoxSignal.connect(self.handle_search_box_visibility) - self.tableView.playlistStatsSignal.connect(self.set_permanent_status_bar_message) + self.tableView.playlistStatsSignal.connect( + self.set_permanent_status_bar_message + ) self.tableView.load_music_table() self.player.playlistNextSignal.connect(self.on_next_clicked) # playlistTreeView - self.playlistTreeView.playlistChoiceSignal.connect(self.tableView.load_music_table) + self.playlistTreeView.playlistChoiceSignal.connect( + self.tableView.load_music_table + ) self.playlistTreeView.allSongsSignal.connect(self.tableView.load_music_table) # albumGraphicsView - self.albumGraphicsView.albumArtDropped.connect(self.set_album_art_for_selected_songs) - self.albumGraphicsView.albumArtDeleted.connect(self.delete_album_art_for_current_song) - + self.albumGraphicsView.albumArtDropped.connect( + self.set_album_art_for_selected_songs + ) + self.albumGraphicsView.albumArtDeleted.connect( + self.delete_album_art_for_current_song + ) def nothing(self): pass @@ -216,8 +234,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): def closeEvent(self, a0: QCloseEvent | None) -> None: """Save settings when closing the application""" self.config["settings"]["volume"] = str(self.current_volume) - self.config["settings"]["window_size"] = (str(self.width()) + "," + str(self.height())) - self.config['table']['column_ratios'] = ",".join(self.tableView.get_current_header_width_ratios()) + self.config["settings"]["window_size"] = ( + str(self.width()) + "," + str(self.height()) + ) + self.config["table"]["column_ratios"] = ",".join( + self.tableView.get_current_header_width_ratios() + ) self.config["table"]["sort_order"] = self.tableView.sort_config # Save the config try: @@ -225,10 +247,13 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.config.write(configfile) except Exception as e: debug(f"wtf man {e}") + debug("Done writing config") # auto export any playlists that want it try: with DBA.DBAccess() as db: - result = db.query('SELECT id FROM playlist WHERE auto_export = true;', ()) + result = db.query( + "SELECT id FROM playlist WHERE auto_export = true;", () + ) ids = [id[0] for id in result] for id in ids: export_playlist_by_id(id) @@ -298,8 +323,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): return row: int = index.row() prev_row: int = row - 1 - prev_index: QModelIndex = self.tableView.proxymodel.index(prev_row, index.column()) - prev_filepath = prev_index.siblingAtColumn(self.headers.db_list.index("filepath")).data() + prev_index: QModelIndex = self.tableView.proxymodel.index( + prev_row, index.column() + ) + prev_filepath = prev_index.siblingAtColumn( + self.headers.db_list.index("filepath") + ).data() if prev_filepath is None: return @@ -318,8 +347,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): return row: int = index.row() next_row: int = row + 1 - next_index: QModelIndex = self.tableView.proxymodel.index(next_row, index.column()) - next_filepath = next_index.siblingAtColumn(self.headers.db_list.index("filepath")).data() + next_index: QModelIndex = self.tableView.proxymodel.index( + next_row, index.column() + ) + next_filepath = next_index.siblingAtColumn( + self.headers.db_list.index("filepath") + ).data() if next_filepath is None: return self.play_audio_file(next_filepath) @@ -368,8 +401,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): def load_config(self) -> None: """does what it says""" cfg_file = ( - Path(user_config_dir(appname="musicpom", - appauthor="billypom")) / "config.ini" + Path(user_config_dir(appname="musicpom", appauthor="billypom")) + / "config.ini" ) self.config.read(cfg_file) debug("load_config()") @@ -447,7 +480,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): for song in selected_songs: debug( f"main.py set_album_art_for_selected_songs() | updating album art for { - song}" + song + }" ) set_album_art(song, album_art_path) @@ -474,8 +508,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.playbackSlider.setMaximum(self.player.duration()) slider_position = self.player.position() self.playbackSlider.setValue(slider_position) - current_minutes, current_seconds = divmod( - slider_position / 1000, 60) + current_minutes, current_seconds = divmod(slider_position / 1000, 60) duration_minutes, duration_seconds = divmod( self.player.duration() / 1000, 60 ) @@ -494,6 +527,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): """ updates the status bar when progress is emitted """ + # breakpoint() self.show_status_bar_message(data) # ____________________ @@ -526,8 +560,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): def create_playlist(self) -> None: """Creates a database record for a playlist, given a name""" window = CreatePlaylistWindow(self.playlistCreatedSignal) - window.playlistCreatedSignal.connect( - self.add_latest_playlist_to_tree) # type: ignore + window.playlistCreatedSignal.connect(self.add_latest_playlist_to_tree) # type: ignore window.exec_() def import_playlist(self) -> None: @@ -592,22 +625,21 @@ def update_database_file() -> bool: Reads the database file (specified by config file) """ cfg_file = ( - Path(user_config_dir(appname="musicpom", - appauthor="billypom")) / "config.ini" + Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini" ) - cfg_path = str(Path(user_config_dir( - appname="musicpom", appauthor="billypom"))) + cfg_path = str(Path(user_config_dir(appname="musicpom", appauthor="billypom"))) config = ConfigParser() config.read(cfg_file) db_filepath: str - db_filepath= config.get("settings", "db") + db_filepath = config.get("settings", "db") # If the database location isnt set at the config location, move it if not db_filepath.startswith(cfg_path): new_path = f"{cfg_path}/{db_filepath}" debug( - f"Set new config [db] database path: \n> Current: { - db_filepath}\n> New:{new_path}" + f"Set new config [db] database path: \n> Current: {db_filepath}\n> New:{ + new_path + }" ) config["settings"]["db"] = new_path # Save the config @@ -641,11 +673,9 @@ def update_config_file() -> ConfigParser: If the user config file is not up to date, update it with examples from sample config """ cfg_file = ( - Path(user_config_dir(appname="musicpom", - appauthor="billypom")) / "config.ini" + Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini" ) - cfg_path = str(Path(user_config_dir( - appname="musicpom", appauthor="billypom"))) + cfg_path = str(Path(user_config_dir(appname="musicpom", appauthor="billypom"))) # If config path doesn't exist, create it if not os.path.exists(cfg_path): @@ -693,11 +723,11 @@ if __name__ == "__main__": format="{%(filename)s:%(lineno)d} %(levelname)s - %(message)s", handlers=handlers, ) - debug('--------- musicpom debug started') - debug('--------------------------------') + debug("--------- musicpom debug started") + debug("--------------------------------") debug(handlers) - debug('--------------------------------') - debug('--------------------------------') + debug("--------------------------------") + debug("--------------------------------") # Initialization config: ConfigParser = update_config_file() if not update_database_file(): @@ -713,7 +743,7 @@ if __name__ == "__main__": try: # Dark theme >:3 qdarktheme.setup_theme() - #qdarktheme.setup_theme("auto") # this is supposed to work but doesnt + # qdarktheme.setup_theme("auto") # this is supposed to work but doesnt except Exception: pass # Show the UI diff --git a/utils/__init__.py b/utils/__init__.py index 313d3eb..b3bf1e5 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -4,7 +4,7 @@ from .initialize_db import initialize_db from .safe_get import safe_get from .get_album_art import get_album_art from .get_tags import get_tags, id3_remap -from .get_reorganize_vars import get_reorganize_vars +from .get_reorganize_vars import get_reorganize_vars, parse_artist_album from .set_tag import set_tag from .delete_song_id_from_database import delete_song_id_from_database from .batch_delete_filepaths_from_database import batch_delete_filepaths_from_database diff --git a/utils/export_playlist_by_id.py b/utils/export_playlist_by_id.py index 32b475f..d34f000 100644 --- a/utils/export_playlist_by_id.py +++ b/utils/export_playlist_by_id.py @@ -1,26 +1,31 @@ +from ast import parse import os from PyQt5.QtCore import QThreadPool import DBA import logging -from utils import get_reorganize_vars, Worker +from utils import get_reorganize_vars, Worker, parse_artist_album +from pathlib import Path +from time import time + def export_playlist_by_id(playlist_db_id: int) -> bool: """ Exports a playlist to its defined auto_export_path, by database ID """ + logging.debug(f"Exporting playlist id: {playlist_db_id}") threadpool = QThreadPool() try: with DBA.DBAccess() as db: - result = db.query(''' + result = db.query( + """ SELECT auto_export_path, path_prefix FROM playlist WHERE id = ? - ''', (playlist_db_id,) - ) + """, + (playlist_db_id,), + ) auto_export_path = result[0][0] path_prefix = result[0][1] except Exception as e: - logging.error( - f"export_playlist_by_id.py | Could not contact database: {e}" - ) + logging.error(f"export_playlist_by_id.py | Could not contact database: {e}") return False # If the prefix is null, then use an empty string @@ -40,7 +45,7 @@ def export_playlist_by_id(playlist_db_id: int) -> bool: WHERE sp.playlist_id = ?;""", (playlist_db_id,), ) - db_paths = [path[0] for path in data] + db_paths = [Path(path[0]) for path in data] except Exception as e: logging.error( f"export_playlist_by_id.py | Could not retrieve records from playlist: {e}" @@ -50,13 +55,11 @@ def export_playlist_by_id(playlist_db_id: int) -> bool: # Gather playlist song paths write_paths = [] # Relative paths + logging.debug("Creating relative paths...") for song in db_paths: - artist, album = get_reorganize_vars(song) - write_path = os.path.join( - path_prefix, artist, album, song.split("/")[-1] + "\n" - ) - write_paths.append(str(write_path)) - + artist, album = parse_artist_album(song) + write_path = Path(path_prefix) / artist / album / song.name + write_paths.append(str(write_path) + "\n") worker = Worker(write_to_playlist_file, write_paths, auto_export_path) # worker.signals.signal_finished.connect(None) @@ -64,10 +67,15 @@ def export_playlist_by_id(playlist_db_id: int) -> bool: threadpool.start(worker) return True -def write_to_playlist_file(paths: list[str], outfile: str, progress_callback=None) -> None: + +def write_to_playlist_file( + paths: list[str], outfile: str, progress_callback=None +) -> None: """ Writes a list of strings to a m3u file """ os.makedirs(os.path.dirname(outfile), exist_ok=True) + logging.debug("Writing paths to m3u file") with open(outfile, "w") as f: f.writelines(paths) + logging.debug("Done writing m3u files") diff --git a/utils/get_reorganize_vars.py b/utils/get_reorganize_vars.py index e116047..a3ef9d3 100644 --- a/utils/get_reorganize_vars.py +++ b/utils/get_reorganize_vars.py @@ -1,7 +1,8 @@ from mutagen.id3 import ID3 +from pathlib import Path -def get_reorganize_vars(filepath: str) -> tuple[str, str]: +def get_reorganize_vars(filepath: Path) -> tuple[str, str]: """ Takes in a path to an audio file returns the (artist, album) as a tuple of strings @@ -9,6 +10,7 @@ def get_reorganize_vars(filepath: str) -> tuple[str, str]: if no artist or album or ID3 tags at all are found, function will return ("Unknown Artist", "Unknown Album") """ + # logging.debug(f"getting reorganize vars for {filepath}") # TODO: fix this func. id3_remap(get_tags()) # or is what i have less memory so more better? :shrug: audio = ID3(filepath) @@ -27,3 +29,78 @@ def get_reorganize_vars(filepath: str) -> tuple[str, str]: album = "Unknown Album" return artist, album + + +def synchsafe_to_int(b): + """Convert 4 synchsafe bytes to int (ID3v2.4).""" + return (b[0] << 21) | (b[1] << 14) | (b[2] << 7) | b[3] + + +def decode_text_frame(data): + """Decode ID3 text frame payload.""" + if not data: + return None + + encoding_byte = data[0] + text_bytes = data[1:] + + if encoding_byte == 0: + return text_bytes.decode("iso-8859-1", errors="replace").rstrip("\x00") + elif encoding_byte == 1: + return text_bytes.decode("utf-16", errors="replace").rstrip("\x00") + elif encoding_byte == 2: + return text_bytes.decode("utf-16-be", errors="replace").rstrip("\x00") + elif encoding_byte == 3: + return text_bytes.decode("utf-8", errors="replace").rstrip("\x00") + else: + return None + + +def parse_artist_album(filepath) -> tuple[str, str]: + with open(filepath, "rb") as f: + header = f.read(10) + + if len(header) < 10 or header[0:3] != b"ID3": + raise ValueError("No ID3v2 tag found") + + version_major = header[3] + tag_size = synchsafe_to_int(header[6:10]) + + artist = None + album = None + + start_pos = f.tell() + + while f.tell() - start_pos < tag_size: + frame_header = f.read(10) + if len(frame_header) < 10: + break + + frame_id = frame_header[0:4].decode("ascii", errors="replace") + + # Stop at padding + if frame_id.strip("\x00") == "": + break + + if version_major == 4: + frame_size = synchsafe_to_int(frame_header[4:8]) + else: + frame_size = int.from_bytes(frame_header[4:8], "big") + + # Skip flags (bytes 8–9 already included in header) + frame_data = f.read(frame_size) + + if frame_id == "TPE1": + artist = decode_text_frame(frame_data) + elif frame_id == "TALB": + album = decode_text_frame(frame_data) + + if artist and album: + break + + if not artist: + artist = "Unknown Arist" + if not album: + album = "Unknown Album" + + return artist, album