from mutagen.easyid3 import EasyID3 import DBA from PyQt5.QtGui import ( QStandardItem, QStandardItemModel, QKeySequence, QDragEnterEvent, QDropEvent, ) from PyQt5.QtWidgets import QTableView, QShortcut, QMessageBox, QAbstractItemView from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, QTimer from utils import add_files_to_library from utils import update_song_in_library from utils import get_id3_tags from utils import get_album_art from utils import set_id3_tag import logging import configparser import os import shutil class MusicTable(QTableView): playPauseSignal = pyqtSignal() enterKey = pyqtSignal() def __init__(self, parent=None): # QTableView.__init__(self, parent) super().__init__(parent) self.model = QStandardItemModel(self) # Necessary for actions related to cell values self.setModel(self.model) # Same as above self.config = configparser.ConfigParser() self.config.read("config.ini") # gui names of headers self.table_headers = [ "title", "artist", "album", "genre", "codec", "year", "path", ] # id3 names of headers self.id3_headers = [ "title", "artist", "album", "content_type", None, None, None, ] # db names of headers self.database_columns = str(self.config["table"]["columns"]).split(",") self.vertical_scroll_position = 0 self.songChanged = None self.selected_song_filepath = None self.current_song_filepath = None # self.tableView.resizeColumnsToContents() self.clicked.connect(self.set_selected_song_filepath) # doubleClicked is a built in event for QTableView - we listen for this event and run set_current_song_filepath self.doubleClicked.connect(self.set_current_song_filepath) self.enterKey.connect(self.set_current_song_filepath) self.fetch_library() self.setup_keyboard_shortcuts() self.model.dataChanged.connect(self.on_cell_data_changed) # editing cells self.model.layoutChanged.connect(self.restore_scroll_position) def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.accept() else: event.ignore() def dragMoveEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.accept() else: event.ignore() def dropEvent(self, event: QDropEvent): if event.mimeData().hasUrls(): files = [] for url in event.mimeData().urls(): if url.isLocalFile(): files.append(url.path()) self.add_files(files) event.accept() else: event.ignore() def setup_keyboard_shortcuts(self): """Setup shortcuts here""" shortcut = QShortcut(QKeySequence("Ctrl+Shift+R"), self) shortcut.activated.connect(self.reorganize_selected_files) def on_cell_data_changed(self, topLeft: QModelIndex, bottomRight: QModelIndex): """Handles updating ID3 tags when data changes in a cell""" print("on_cell_data_changed") id_index = self.model.index(topLeft.row(), 0) # ID is column 0, always library_id = self.model.data(id_index, Qt.UserRole) # filepath is always the last column filepath_column_idx = self.model.columnCount() - 1 filepath_index = self.model.index(topLeft.row(), filepath_column_idx) # exact index of the edited cell in 2d space filepath = self.model.data(filepath_index) # filepath # update the ID3 information user_input_data = topLeft.data() edited_column_name = self.database_columns[topLeft.column()] print(f"edited column name: {edited_column_name}") response = set_id3_tag(filepath, edited_column_name, user_input_data) if response: # Update the library with new metadata update_song_in_library(library_id, edited_column_name, user_input_data) def reorganize_selected_files(self): """Ctrl+Shift+R = Reorganize""" filepaths = self.get_selected_songs_filepaths() # Confirmation screen (yes, no) reply = QMessageBox.question( self, "Confirmation", "Are you sure you want to reorganize these files?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, ) if reply: # Get target directory target_dir = str(self.config["directories"]["reorganize_destination"]) for filepath in filepaths: try: # Read file metadata audio = EasyID3(filepath) artist = audio.get("artist", ["Unknown Artist"])[0] album = audio.get("album", ["Unknown Album"])[0] # Determine the new path that needs to be made new_path = os.path.join( target_dir, artist, album, os.path.basename(filepath) ) # Create the directories if they dont exist os.makedirs(os.path.dirname(new_path), exist_ok=True) # Move the file to the new directory shutil.move(filepath, new_path) # Update the db with DBA.DBAccess() as db: db.query( "UPDATE library SET filepath = ? WHERE filepath = ?", (new_path, filepath), ) print(f"Moved: {filepath} -> {new_path}") except Exception as e: print(f"Error moving file: {filepath} | {e}") # Draw the rest of the owl self.model.dataChanged.disconnect(self.on_cell_data_changed) self.fetch_library() self.model.dataChanged.connect(self.on_cell_data_changed) QMessageBox.information( self, "Reorganization complete", "Files successfully reorganized" ) def keyPressEvent(self, event): """Press a key. Do a thing""" key = event.key() if key == Qt.Key_Space: # Spacebar to play/pause self.toggle_play_pause() elif key == Qt.Key_Up: # Arrow key navigation current_index = self.currentIndex() new_index = self.model.index( current_index.row() - 1, current_index.column() ) if new_index.isValid(): self.setCurrentIndex(new_index) elif key == Qt.Key_Down: # Arrow key navigation current_index = self.currentIndex() new_index = self.model.index( current_index.row() + 1, current_index.column() ) if new_index.isValid(): self.setCurrentIndex(new_index) elif key in (Qt.Key_Return, Qt.Key_Enter): if self.state() != QAbstractItemView.EditingState: self.enterKey.emit() # Enter key detected else: super().keyPressEvent(event) else: # Default behavior super().keyPressEvent(event) def toggle_play_pause(self): """Toggles the currently playing song by emitting a signal""" if not self.current_song_filepath: self.set_current_song_filepath() self.playPauseSignal.emit() def fetch_library(self): """Initialize the tableview model""" self.vertical_scroll_position = ( self.verticalScrollBar().value() ) # Get my scroll position before clearing # temporarily disconnect the datachanged signal to avoid EVERY SONG getting triggered self.model.clear() self.model.setHorizontalHeaderLabels(self.table_headers) # Fetch library data with DBA.DBAccess() as db: data = db.query( "SELECT id, title, artist, album, genre, codec, album_date, filepath FROM library;", (), ) # Populate the model for row_data in data: id, *rest_of_data = row_data items = [QStandardItem(str(item)) for item in rest_of_data] self.model.appendRow(items) # store id using setData - useful for later faster db fetching row = self.model.rowCount() - 1 for item in items: item.setData(id, Qt.UserRole) # Update the viewport/model self.model.layoutChanged.emit() # emits a signal that the view should be updated def restore_scroll_position(self) -> None: """Restores the scroll position""" QTimer.singleShot( 100, lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position), ) def add_files(self, files) -> None: """When song(s) added to the library, update the tableview model - Drag & Drop song(s) on tableView - File > Open > List of song(s) """ number_of_files_added = add_files_to_library(files) if number_of_files_added: self.model.dataChanged.disconnect(self.on_cell_data_changed) self.fetch_library() self.model.dataChanged.connect(self.on_cell_data_changed) def get_selected_rows(self) -> list[int]: """Returns a list of indexes for every selected row""" selection_model = self.selectionModel() return [index.row() for index in selection_model.selectedRows()] def get_selected_songs_filepaths(self) -> list[str]: """Returns a list of the filepaths for the currently selected songs""" selected_rows = self.get_selected_rows() filepaths = [] for row in selected_rows: idx = self.model.index(row, self.table_headers.index("path")) filepaths.append(idx.data()) return filepaths def get_selected_song_filepath(self) -> str: """Returns the selected songs filepath""" return self.selected_song_filepath def get_selected_song_metadata(self) -> dict: """Returns the selected song's ID3 tags""" return get_id3_tags(self.selected_song_filepath) def get_current_song_filepath(self) -> str: """Returns the currently playing song filepath""" return self.current_song_filepath def get_current_song_metadata(self) -> dict: """Returns the currently playing song's ID3 tags""" return get_id3_tags(self.current_song_filepath) def get_current_song_album_art(self) -> None: """Returns the APIC data (album art lol) for the currently playing song""" return get_album_art(self.current_song_filepath) def set_selected_song_filepath(self) -> None: """Sets the filepath of the currently selected song""" self.selected_song_filepath = ( self.currentIndex().siblingAtColumn(self.table_headers.index("path")).data() ) def set_current_song_filepath(self) -> None: """Sets the filepath of the currently playing song""" # Setting the current song filepath automatically plays that song # self.tableView listens to this function and plays the audio file located at self.current_song_filepath self.current_song_filepath = ( self.currentIndex().siblingAtColumn(self.table_headers.index("path")).data() ) def load_qapp(self, qapp) -> None: """Necessary for talking between components...""" self.qapp = qapp