diff --git a/assets/default_album_art.jpg b/assets/default_album_art.jpg new file mode 100644 index 0000000..697b0ed Binary files /dev/null and b/assets/default_album_art.jpg differ diff --git a/components/AlbumArtGraphicsView.py b/components/AlbumArtGraphicsView.py new file mode 100644 index 0000000..f095daa --- /dev/null +++ b/components/AlbumArtGraphicsView.py @@ -0,0 +1,107 @@ +import os +import tempfile +from PyQt5.QtWidgets import QGraphicsView, QMenu, QAction +from PyQt5.QtCore import QEvent, Qt, pyqtSignal, QUrl, QPoint +from PyQt5.QtGui import QDragEnterEvent, QDropEvent, QClipboard, QPixmap + + +class AlbumArtGraphicsView(QGraphicsView): + albumArtDropped = pyqtSignal(str) + albumArtDeleted = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.showContextMenu) + self.qapp = None + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + """Handles drag and drop pic onto view to add album art to songs""" + urls = event.mimeData().urls() + if urls: + first_url = urls[0].toLocalFile() + if first_url.lower().endswith((".png", ".jpg", ".jpeg")): + print(f"dropped {first_url}") + self.albumArtDropped.emit( + first_url + ) # emit signal that album art was dropped + event.accept() + return + event.ignore() + + def showContextMenu(self, position: QPoint): + """Handles showing a context menu when right clicking the album art""" + contextMenu = QMenu(self) # Create the menu + + copyAction = QAction("Copy", self) # Add the actions + pasteAction = QAction("Paste", self) + deleteAction = QAction("Delete", self) + + copyAction.triggered.connect( + self.copy_album_art_to_clipboard + ) # Add the signal triggers + pasteAction.triggered.connect(self.paste_album_art_from_clipboard) + deleteAction.triggered.connect(self.delete_album_art) + + contextMenu.addAction(copyAction) # Add actions to the menu + contextMenu.addAction(pasteAction) + contextMenu.addAction(deleteAction) + # DO + contextMenu.exec_(self.mapToGlobal(position)) # Show the menu + + def copy_album_art_to_clipboard(self): + """Copies album art to the clipboard""" + if not self.scene().items(): + return # dont care if no pic + clipboard = self.qapp.clipboard() + pixmap_item = self.scene().items()[0] + clipboard.setPixmap(pixmap_item.pixmap()) + + def paste_album_art_from_clipboard(self): + """Handles pasting album art into a song via system clipboard""" + clipboard = self.qapp.clipboard() + mime_data = clipboard.mimeData() + # Check if clipboard data is raw data or filepath + if mime_data.hasUrls(): + for url in mime_data.urls(): + file_path = url.toLocalFile() + if os.path.isfile(file_path): + pixmap = QPixmap(file_path) + else: + pixmap = clipboard.pixmap() + # Put image on screen and emit signal for ID3 tags to be updated + if not pixmap.isNull(): # Add pixmap raw data image + try: + self.scene().clear() # clear the scene + except Exception: + pass + self.scene().addPixmap(pixmap) + # Create temp file for pic + temp_file, file_path = tempfile.mkstemp(suffix=".jpg") + os.close(temp_file) # close the file + pixmap.save(file_path, "JPEG") + self.albumArtDropped.emit( + file_path + ) # emit signal that album art was pasted + + def delete_album_art(self): + """Emits a signal for the album art to be deleted""" + # Signal emits and ID3 tag is updated + self.albumArtDeleted.emit() + + def load_qapp(self, qapp): + """Necessary for talking between components...""" + self.qapp = qapp diff --git a/components/MusicTable.py b/components/MusicTable.py index cbb0d05..023744d 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -1,6 +1,12 @@ from mutagen.easyid3 import EasyID3 import DBA -from PyQt5.QtGui import QStandardItem, QStandardItemModel, QKeySequence, QDragEnterEvent, QDropEvent +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 @@ -17,16 +23,37 @@ 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.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') - self.table_headers = ['title', 'artist', 'album', 'genre', 'codec', 'year', 'path'] # gui names of headers - self.id3_headers = ['title', 'artist', 'album', 'content_type', ] - self.database_columns = str(self.config['table']['columns']).split(',') # db names of headers + 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 @@ -38,24 +65,21 @@ class MusicTable(QTableView): 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.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 = [] @@ -67,103 +91,122 @@ class MusicTable(QTableView): 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 + 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_column_idx = self.model.columnCount() - 1 # filepath is always the last column - 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 + # 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) + 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']) + 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] + 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)) + 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}') + 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}') + 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') - + 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 + if key == Qt.Key_Space: # Spacebar to play/pause self.toggle_play_pause() - elif key == Qt.Key_Up: # Arrow key navigation + elif key == Qt.Key_Up: # Arrow key navigation current_index = self.currentIndex() - new_index = self.model.index(current_index.row() - 1, current_index.column()) + 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 + elif key == Qt.Key_Down: # Arrow key navigation current_index = self.currentIndex() - new_index = self.model.index(current_index.row() + 1, current_index.column()) + 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 + self.enterKey.emit() # Enter key detected else: super().keyPressEvent(event) - else: # Default behavior + 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 + 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;', ()) + 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 @@ -174,16 +217,16 @@ class MusicTable(QTableView): 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 + self.model.layoutChanged.emit() # emits a signal that the view should be updated - - def restore_scroll_position(self): + def restore_scroll_position(self) -> None: """Restores the scroll position""" - print(f'Returning to {self.vertical_scroll_position}') - QTimer.singleShot(100, lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position)) + QTimer.singleShot( + 100, + lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position), + ) - - def add_files(self, files): + 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) @@ -194,62 +237,54 @@ class MusicTable(QTableView): self.fetch_library() self.model.dataChanged.connect(self.on_cell_data_changed) - - def set_selected_song_filepath(self): - """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): - """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 get_selected_rows(self): + 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): + 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')) + idx = self.model.index(row, self.table_headers.index("path")) filepaths.append(idx.data()) return filepaths - - def get_selected_song_filepath(self): + def get_selected_song_filepath(self) -> str: """Returns the selected songs filepath""" return self.selected_song_filepath - - def get_selected_song_metadata(self): + 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): + def get_current_song_filepath(self) -> str: """Returns the currently playing song filepath""" return self.current_song_filepath - - def get_current_song_metadata(self): + 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): + 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 load_qapp(self, qapp): - # why was this necessary again? :thinking: + 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 - - diff --git a/components/__init__.py b/components/__init__.py index 46a0b8d..de53109 100644 --- a/components/__init__.py +++ b/components/__init__.py @@ -1,4 +1,5 @@ from .MusicTable import MusicTable +from .AlbumArtGraphicsView import AlbumArtGraphicsView from .AudioVisualizer import AudioVisualizer from .PreferencesWindow import PreferencesWindow from .ErrorDialog import ErrorDialog diff --git a/main.py b/main.py index 01d198d..a52fbf1 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,18 @@ -from ui import Ui_MainWindow -from PyQt5.QtWidgets import QMainWindow, QApplication, QGraphicsScene, \ - QHeaderView, QGraphicsPixmapItem +import os +import configparser +import sys import qdarktheme +from pyqtgraph import mkBrush +from mutagen.id3 import ID3, APIC, error +from mutagen.mp3 import MP3 +from ui import Ui_MainWindow +from PyQt5.QtWidgets import ( + QMainWindow, + QApplication, + QGraphicsScene, + QHeaderView, + QGraphicsPixmapItem, +) from PyQt5.QtCore import QUrl, QTimer, QEvent, Qt, QModelIndex from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe from PyQt5.QtGui import QPixmap, QStandardItemModel @@ -9,10 +20,6 @@ from utils import scan_for_music from utils import delete_and_create_library_database from components import AudioVisualizer from components import PreferencesWindow -from pyqtgraph import mkBrush -import configparser -import os -import sys # Create ui.py file from Qt Designer # pyuic5 ui.ui -o ui.py @@ -22,7 +29,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): def __init__(self, qapp): super(ApplicationWindow, self).__init__() self.setupUi(self) - self.setWindowTitle('MusicPom') + self.setWindowTitle("MusicPom") self.selected_song_filepath = None self.current_song_filepath = None self.current_song_metadata = None @@ -31,9 +38,10 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.qapp = qapp # print(f'ApplicationWindow self.qapp: {self.qapp}') self.tableView.load_qapp(self.qapp) + self.albumGraphicsView.load_qapp(self.qapp) # print(f'tableView type: {type(self.tableView)}') self.config = configparser.ConfigParser() - self.config.read('config.ini') + self.config.read("config.ini") global stopped stopped = False # Initialization @@ -48,74 +56,111 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): # Provides faster updates than move_slider self.probe.setSource(self.player) self.probe.audioBufferProbed.connect(self.process_probe) - + # Slider Timer (realtime playback feedback horizontal bar) - self.timer.start(150) # 150ms update interval solved problem with drag seeking halting playback + self.timer.start( + 150 + ) # 150ms update interval solved problem with drag seeking halting playback self.timer.timeout.connect(self.move_slider) # Graphics plot - self.PlotWidget.setXRange(0,100,padding=0) # x axis range - self.PlotWidget.setYRange(0,0.3,padding=0) # y axis range - self.PlotWidget.getAxis('bottom').setTicks([]) # Remove x-axis ticks - self.PlotWidget.getAxis('bottom').setLabel('') # Remove x-axis label + self.PlotWidget.setXRange(0, 100, padding=0) # x axis range + self.PlotWidget.setYRange(0, 0.3, padding=0) # y axis range + self.PlotWidget.getAxis("bottom").setTicks([]) # Remove x-axis ticks + self.PlotWidget.getAxis("bottom").setLabel("") # Remove x-axis label self.PlotWidget.setLogMode(False, False) # Remove y-axis labels and decorations - self.PlotWidget.getAxis('left').setTicks([]) # Remove y-axis ticks - self.PlotWidget.getAxis('left').setLabel('') # Remove y-axis label + self.PlotWidget.getAxis("left").setTicks([]) # Remove y-axis ticks + self.PlotWidget.getAxis("left").setLabel("") # Remove y-axis label # Connections # ! FIXME moving the slider while playing is happening frequently causes playback to halt - the pyqtgraph is also affected by this - self.playbackSlider.sliderMoved[int].connect(lambda: self.player.setPosition(self.playbackSlider.value())) # Move slidet to adjust playback time - self.volumeSlider.sliderMoved[int].connect(lambda: self.volume_changed()) # Move slider to adjust volume - self.playButton.clicked.connect(self.on_play_clicked) # Click to play/pause - self.previousButton.clicked.connect(self.on_previous_clicked) # Click to previous song - self.nextButton.clicked.connect(self.on_next_clicked) # Click to next song - self.actionPreferences.triggered.connect(self.actionPreferencesClicked) # Open preferences menu - self.actionScanLibraries.triggered.connect(self.scan_libraries) # Scan library - self.actionClearDatabase.triggered.connect(self.clear_database) # Clear database + self.playbackSlider.sliderMoved[int].connect( + lambda: self.player.setPosition(self.playbackSlider.value()) + ) # Move slidet to adjust playback time + self.volumeSlider.sliderMoved[int].connect( + lambda: self.volume_changed() + ) # Move slider to adjust volume + self.playButton.clicked.connect(self.on_play_clicked) # Click to play/pause + self.previousButton.clicked.connect( + self.on_previous_clicked + ) # Click to previous song + self.nextButton.clicked.connect(self.on_next_clicked) # Click to next song + self.actionPreferences.triggered.connect( + self.actionPreferencesClicked + ) # Open preferences menu + self.actionScanLibraries.triggered.connect(self.scan_libraries) # Scan library + self.actionClearDatabase.triggered.connect( + self.clear_database + ) # Clear database ## tableView - self.tableView.doubleClicked.connect(self.play_audio_file) # Listens for the double click event, then plays the song - self.tableView.enterKey.connect(self.play_audio_file) # Listens for the enter key event, then plays the song - self.tableView.playPauseSignal.connect(self.on_play_clicked) # Spacebar toggle play/pause signal - self.tableView.viewport().installEventFilter(self) # for drag & drop functionality + self.tableView.doubleClicked.connect( + self.play_audio_file + ) # Listens for the double click event, then plays the song + self.tableView.enterKey.connect( + self.play_audio_file + ) # Listens for the enter key event, then plays the song + self.tableView.playPauseSignal.connect( + self.on_play_clicked + ) # Spacebar toggle play/pause signal + # albumGraphicsView + self.albumGraphicsView.albumArtDropped.connect( + self.set_album_art_for_selected_songs + ) + self.albumGraphicsView.albumArtDeleted.connect( + self.delete_album_art_for_selected_songs + ) + self.tableView.viewport().installEventFilter( + self + ) # for drag & drop functionality # set column widths - table_view_column_widths = str(self.config['table']['column_widths']).split(',') + table_view_column_widths = str(self.config["table"]["column_widths"]).split(",") for i in range(self.tableView.model.columnCount()): self.tableView.setColumnWidth(i, int(table_view_column_widths[i])) # dont extend last column past table view border self.tableView.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.tableView.horizontalHeader().setStretchLastSection(False) - - def closeEvent(self, event): + def closeEvent(self, event) -> None: """Save settings when closing the application""" # MusicTable/tableView column widths list_of_column_widths = [] for i in range(self.tableView.model.columnCount()): list_of_column_widths.append(str(self.tableView.columnWidth(i))) - column_widths_as_string = ','.join(list_of_column_widths) - self.config['table']['column_widths'] = column_widths_as_string + column_widths_as_string = ",".join(list_of_column_widths) + self.config["table"]["column_widths"] = column_widths_as_string # Save the config - with open('config.ini', 'w') as configfile: + with open("config.ini", "w") as configfile: self.config.write(configfile) super().closeEvent(event) - - def play_audio_file(self): + def play_audio_file(self) -> None: """Start playback of tableView.current_song_filepath track & moves playback slider""" - self.current_song_metadata = self.tableView.get_current_song_metadata() # get metadata + self.current_song_metadata = ( + self.tableView.get_current_song_metadata() + ) # get metadata self.current_song_album_art = self.tableView.get_current_song_album_art() - url = QUrl.fromLocalFile(self.tableView.get_current_song_filepath()) # read the file - content = QMediaContent(url) # load the audio content - self.player.setMedia(content) # what content to play - self.player.play() # play - self.move_slider() # mover + url = QUrl.fromLocalFile( + self.tableView.get_current_song_filepath() + ) # read the file + content = QMediaContent(url) # load the audio content + self.player.setMedia(content) # what content to play + self.player.play() # play + self.move_slider() # mover # assign metadata # FIXME when i change tinytag to something else - artist = self.current_song_metadata["artist"][0] if "artist" in self.current_song_metadata else None - album = self.current_song_metadata["album"][0] if "album" in self.current_song_metadata else None + artist = ( + self.current_song_metadata["artist"][0] + if "artist" in self.current_song_metadata + else None + ) + album = ( + self.current_song_metadata["album"][0] + if "album" in self.current_song_metadata + else None + ) title = self.current_song_metadata["title"][0] # edit labels self.artistLabel.setText(artist) @@ -124,19 +169,24 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): # set album artwork self.load_album_art(self.current_song_album_art) - def load_album_art(self, album_art_data): + def load_album_art(self, album_art_data) -> None: """Displays the album art for the currently playing track in the GraphicsView""" if self.current_song_album_art: # Clear the scene - self.album_art_scene.clear() + try: + self.album_art_scene.clear() + except Exception: + pass # Reset the scene self.albumGraphicsView.setScene(None) # Create pixmap for album art pixmap = QPixmap() - pixmap.loadFromData(self.current_song_album_art) + pixmap.loadFromData(album_art_data) # Create a QGraphicsPixmapItem for more control over pic pixmapItem = QGraphicsPixmapItem(pixmap) - pixmapItem.setTransformationMode(Qt.SmoothTransformation) # For better quality scaling + pixmapItem.setTransformationMode( + Qt.SmoothTransformation + ) # For better quality scaling # Add pixmap item to the scene self.album_art_scene.addItem(pixmapItem) # Set the scene @@ -144,32 +194,82 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): # Adjust the album art scaling self.adjustPixmapScaling(pixmapItem) - def adjustPixmapScaling(self, pixmapItem): + def adjustPixmapScaling(self, pixmapItem) -> None: """Adjust the scaling of the pixmap item to fit the QGraphicsView, maintaining aspect ratio""" viewWidth = self.albumGraphicsView.width() viewHeight = self.albumGraphicsView.height() pixmapSize = pixmapItem.pixmap().size() - # Calculate scaling factor while maintaining aspect ratio scaleX = viewWidth / pixmapSize.width() scaleY = viewHeight / pixmapSize.height() scaleFactor = min(scaleX, scaleY) - # Apply scaling to the pixmap item pixmapItem.setScale(scaleFactor) - def update_audio_visualization(self): + def set_album_art_for_selected_songs(self, album_art_path: str) -> None: + """Sets the ID3 tag APIC (album art) for all selected song filepaths""" + selected_songs = self.tableView.get_selected_songs_filepaths() + for song in selected_songs: + print(f"updating album art for {song}") + self.update_album_art_for_song(song, album_art_path) + + def update_album_art_for_song( + self, song_file_path: str, album_art_path: str + ) -> None: + """Updates the ID3 tag APIC (album art) for 1 song""" + audio = MP3(song_file_path, ID3=ID3) + # Remove existing APIC Frames (album art) + if audio.tags is not None: + audio.tags.delall("APIC") + # Add the album art + with open(album_art_path, "rb") as album_art_file: + if album_art_path.endswith(".jpg") or album_art_path.endswith(".jpeg"): + audio.tags.add( + APIC( + encoding=3, # 3 = utf-8 + mime="image/jpeg", + type=3, # 3 = cover image + desc="Cover", + data=album_art_file.read(), + ) + ) + elif album_art_path.endswith(".png"): + audio.tags.add( + APIC( + encoding=3, # 3 = utf-8 + mime="image/png", + type=3, # 3 = cover image + desc="Cover", + data=album_art_file.read(), + ) + ) + audio.save() + + def delete_album_art_for_selected_songs(self) -> None: + """Handles deleting the ID3 tag APIC (album art) for all selected songs""" + filepaths = self.tableView.get_selected_songs_filepaths() + for file in filepaths: + # delete APIC data + try: + audio = ID3(file) + if "APIC:" in audio: + del audio["APIC"] + audio.save() + except Exception as e: + print(f"Error processing {file}: {e}") + + def update_audio_visualization(self) -> None: """Handles upading points on the pyqtgraph visual""" self.clear_audio_visualization() self.y = self.audio_visualizer.get_amplitudes() self.x = [i for i in range(len(self.y))] - self.PlotWidget.plot(self.x, self.y, fillLevel=0, fillBrush=mkBrush('b')) + self.PlotWidget.plot(self.x, self.y, fillLevel=0, fillBrush=mkBrush("b")) self.PlotWidget.show() - def clear_audio_visualization(self): + def clear_audio_visualization(self) -> None: self.PlotWidget.clear() - def move_slider(self): + def move_slider(self) -> None: """Handles moving the playback slider""" if stopped: return @@ -180,14 +280,18 @@ 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) - duration_minutes, duration_seconds = divmod(self.player.duration() / 1000, 60) + duration_minutes, duration_seconds = divmod( + self.player.duration() / 1000, 60 + ) + self.startTimeLabel.setText( + f"{int(current_minutes):02d}:{int(current_seconds):02d}" + ) + self.endTimeLabel.setText( + f"{int(duration_minutes):02d}:{int(duration_seconds):02d}" + ) - self.startTimeLabel.setText(f"{int(current_minutes):02d}:{int(current_seconds):02d}") - self.endTimeLabel.setText(f"{int(duration_minutes):02d}:{int(duration_seconds):02d}") - - def volume_changed(self): + def volume_changed(self) -> None: """Handles volume changes""" try: self.current_volume = self.volumeSlider.value() @@ -195,7 +299,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): except Exception as e: print(f"Changing volume error: {e}") - def on_play_clicked(self): + def on_play_clicked(self) -> None: """Updates the Play & Pause buttons when clicked""" if self.player.state() == QMediaPlayer.PlayingState: self.player.pause() @@ -208,26 +312,26 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.play_audio_file() self.playButton.setText("⏸️") - def on_previous_clicked(self): + def on_previous_clicked(self) -> None: """""" - print('previous') + print("previous") - def on_next_clicked(self): - print('next') + def on_next_clicked(self) -> None: + print("next") - def actionPreferencesClicked(self): + def actionPreferencesClicked(self) -> None: preferences_window = PreferencesWindow(self.config) preferences_window.exec_() # Display the preferences window modally - def scan_libraries(self): + def scan_libraries(self) -> None: scan_for_music() self.tableView.fetch_library() - def clear_database(self): + def clear_database(self) -> None: delete_and_create_library_database() self.tableView.fetch_library() - def process_probe(self, buff): + def process_probe(self, buff) -> None: buff.startTime() self.update_audio_visualization() @@ -238,7 +342,7 @@ if __name__ == "__main__": sys.path.append(project_root) # Start the app app = QApplication(sys.argv) - print(f'main.py app: {app}') + print(f"main.py app: {app}") # Dark theme >:3 qdarktheme.setup_theme() # Show the UI diff --git a/show_id3_tags.py b/show_id3_tags.py new file mode 100644 index 0000000..06b10a0 --- /dev/null +++ b/show_id3_tags.py @@ -0,0 +1,36 @@ +import sys +from mutagen.id3 import ID3, APIC +import os + + +def print_id3_tags(file_path): + """Prints all ID3 tags for a given audio file.""" + try: + audio = ID3(file_path) + except Exception as e: + print(f"Error reading ID3 tags: {e}") + return + + for tag in audio.keys(): + # Special handling for APIC frames (attached pictures) + if isinstance(audio[tag], APIC): + print( + f"{tag}: Picture, MIME type: {audio[tag].mime}, Description: {audio[tag].desc}" + ) + else: + print(f"{tag}: {audio[tag].pprint()}") + + +def main(): + if len(sys.argv) != 2: + print("Usage: python show_id3_tags.py /path/to/audio/file") + sys.exit(1) + file_path = sys.argv[1] + if not os.path.exists(file_path): + print("File does not exist.") + sys.exit(1) + print_id3_tags(file_path) + + +if __name__ == "__main__": + main() diff --git a/ui.py b/ui.py index b70deac..01273c6 100644 --- a/ui.py +++ b/ui.py @@ -25,7 +25,7 @@ class Ui_MainWindow(object): self.vlayoutAlbumArt = QtWidgets.QVBoxLayout() self.vlayoutAlbumArt.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) self.vlayoutAlbumArt.setObjectName("vlayoutAlbumArt") - self.albumGraphicsView = QtWidgets.QGraphicsView(self.centralwidget) + self.albumGraphicsView = AlbumArtGraphicsView(self.centralwidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -33,6 +33,7 @@ class Ui_MainWindow(object): self.albumGraphicsView.setSizePolicy(sizePolicy) self.albumGraphicsView.setMinimumSize(QtCore.QSize(200, 200)) self.albumGraphicsView.setMaximumSize(QtCore.QSize(16777215, 16777215)) + self.albumGraphicsView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.albumGraphicsView.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.albumGraphicsView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.albumGraphicsView.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustIgnored) @@ -153,7 +154,7 @@ class Ui_MainWindow(object): self.verticalLayout_3.setStretch(3, 1) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1152, 24)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1152, 41)) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) self.menuFile.setObjectName("menuFile") @@ -204,5 +205,5 @@ class Ui_MainWindow(object): self.actionPreferences.setStatusTip(_translate("MainWindow", "Open preferences")) self.actionScanLibraries.setText(_translate("MainWindow", "Scan libraries")) self.actionClearDatabase.setText(_translate("MainWindow", "Clear Database")) -from components import MusicTable +from components import AlbumArtGraphicsView, MusicTable from pyqtgraph import PlotWidget diff --git a/ui.ui b/ui.ui index 2c70a72..1c0ef44 100644 --- a/ui.ui +++ b/ui.ui @@ -26,7 +26,7 @@ QLayout::SetFixedSize - + 0 @@ -45,6 +45,9 @@ 16777215 + + Qt::CustomContextMenu + Qt::ScrollBarAlwaysOff @@ -334,6 +337,11 @@ QTableView
components
+ + AlbumArtGraphicsView + QGraphicsView +
components
+
diff --git a/utils/__init__.py b/utils/__init__.py index e45d430..8b90ee6 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -7,3 +7,4 @@ from .update_song_in_library import update_song_in_library from .scan_for_music import scan_for_music from .fft_analyser import FFTAnalyser from .add_files_to_library import add_files_to_library +from .handle_year_and_date_id3_tag import handle_year_and_date_id3_tag diff --git a/utils/get_album_art.py b/utils/get_album_art.py index a9c947b..1afa5ff 100644 --- a/utils/get_album_art.py +++ b/utils/get_album_art.py @@ -4,14 +4,19 @@ def get_album_art(file): """Get the album art for an audio file # Parameters `file` | str | Fully qualified path to file - # Returns - dict of all id3 tags + Data for album art or default file """ + default_image_path = './assets/default_album_art.jpg' try: audio = ID3(file) - album_art = audio.get('APIC:').data if 'APIC:' in audio else None - return album_art + for tag in audio.getall('APIC'): + if tag.type == 3: # 3 is the type for front cover + return tag.data + if audio.getall('APIC'): + return audio.getall('APIC')[0].data except Exception as e: - print(f"Error: {e}") - return {} \ No newline at end of file + print(f"Error retrieving album art: {e}") + with open(default_image_path, 'rb') as file: + print(f'album art type: {type(file.read())}') + return file.read() diff --git a/utils/handle_year_and_date_id3_tag.py b/utils/handle_year_and_date_id3_tag.py new file mode 100644 index 0000000..c904345 --- /dev/null +++ b/utils/handle_year_and_date_id3_tag.py @@ -0,0 +1,21 @@ +import re +from mutagen.id3 import TYER, TDAT + + +def handle_year_and_date_id3_tag(date_str): + """Handles date formatting on update in music table + Date format = YYYY-MM-DD""" + match = re.match(r"(\d{4})[-/](\d{2})[-/](\d{2})", date_str) + if not match: + raise ValueError("Invalid date format") + print(f"handle_year_and_date_id3_tag(): match: {match}") + year, month, day = match.groups() + print(f"handle_year_and_date_id3_tag(): month: {month} | day: {day} | year: {year}") + # only year + if not month or not day: + print(TYER(encoding=3, text=year)) + return TYER(encoding=3, text=year), None + else: + date_value = f"{day}{month}" + print(TYER(encoding=3, text=year), TDAT(encoding=3, text=date_value)) + return TYER(encoding=3, text=year), TDAT(encoding=3, text=date_value) diff --git a/utils/set_id3_tag.py b/utils/set_id3_tag.py index 8996524..924a59d 100644 --- a/utils/set_id3_tag.py +++ b/utils/set_id3_tag.py @@ -1,12 +1,44 @@ import logging from components.ErrorDialog import ErrorDialog +from utils.get_id3_tags import get_id3_tags +from utils.handle_year_and_date_id3_tag import handle_year_and_date_id3_tag from mutagen.id3 import ID3, ID3NoHeaderError, Frame from mutagen.mp3 import MP3 from mutagen.easyid3 import EasyID3 from mutagen.id3 import ( - TIT2, TPE1, TALB, TRCK, TYER, TCON, TPOS, COMM, TPE2, TCOM, TPE3, TPE4, - TCOP, WOAR, USLT, APIC, TENC, TBPM, TKEY, TDAT, TIME, TSSE, TOPE, TEXT, - TOLY, TORY, TPUB, WCOM, WCOP, WOAS, WORS, WPAY, WPUB + TIT2, + TPE1, + TALB, + TRCK, + TYER, + TCON, + TPOS, + COMM, + TPE2, + TCOM, + TPE3, + TPE4, + TCOP, + WOAR, + USLT, + APIC, + TENC, + TBPM, + TKEY, + TDAT, + TIME, + TSSE, + TOPE, + TEXT, + TOLY, + TORY, + TPUB, + WCOM, + WCOP, + WOAS, + WORS, + WPAY, + WPUB, ) id3_tag_mapping = { @@ -15,12 +47,11 @@ id3_tag_mapping = { "album": TALB, # Album/Movie/Show title "album_artist": TPE2, # Band/orchestra/accompaniment "genre": TCON, # Content type - "year": TYER, # Year of recording - "date": TDAT, # Date + # "year": TYER, # Year of recording + # "date": TDAT, # Date "lyrics": USLT, # Unsynchronized lyric/text transcription "track_number": TRCK, # Track number/Position in set "album_cover": APIC, # Attached picture - "composer": TCOM, # Composer "conductor": TPE3, # Conductor/performer refinement "remixed_by": TPE4, # Interpreted, remixed, or otherwise modified by @@ -46,6 +77,7 @@ id3_tag_mapping = { "publishers_official_webpage": WPUB, # Publishers official webpage } + def set_id3_tag(filepath: str, tag_name: str, value: str): """Sets the ID3 tag for a file given a filepath, tag_name, and a value for the tag @@ -56,33 +88,46 @@ def set_id3_tag(filepath: str, tag_name: str, value: str): Returns: True / False""" + print( + f"set_id3_tag(): filepath: {filepath} | tag_name: {tag_name} | value: {value}" + ) try: - try: # Load existing tags + try: # Load existing tags audio_file = MP3(filepath, ID3=ID3) - except ID3NoHeaderError: # Create new tags if none exist + except ID3NoHeaderError: # Create new tags if none exist audio_file = MP3(filepath) audio_file.add_tags() - - if tag_name in id3_tag_mapping: # Tag accounted for + if tag_name == "album_date": + tyer_tag, tdat_tag = handle_year_and_date_id3_tag(value) + # always update TYER + audio_file.tags.add(tyer_tag) + if tdat_tag: + # update TDAT if we have it + audio_file.tags.add(tdat_tag) + elif tag_name in id3_tag_mapping: # Tag accounted for tag_class = id3_tag_mapping[tag_name] # if issubclass(tag_class, EasyID3) or issubclass(tag_class, ID3): # Type safety if issubclass(tag_class, Frame): - audio_file.tags.add(tag_class(encoding=3, text=value)) # Add the tag + audio_file.tags.add(tag_class(encoding=3, text=value)) # Add the tag else: - dialog = ErrorDialog(f'ID3 tag not supported.\nTag: {tag_name}\nTag class: {tag_class}\nValue:{value}') - dialog.exec_() - return False + # dialog = ErrorDialog(f'ID3 tag not supported.\nTag: {tag_name}\nTag class: {tag_class}\nValue:{value}') + # dialog.exec_() + # return False + pass else: - dialog = ErrorDialog(f'Invalid ID3 tag. Tag: {tag_name}, Value:{value}') - dialog.exec_() - return False + # dialog = ErrorDialog(f'Invalid ID3 tag. Tag: {tag_name}, Value:{value}') + # dialog.exec_() + # return False + pass audio_file.save() + print("ID3 tags updated:") + print(get_id3_tags(filepath)) + print("-----") return True except Exception as e: - dialog = ErrorDialog(f'An unhandled exception occurred:\n{e}') + dialog = ErrorDialog(f"An unhandled exception occurred:\n{e}") dialog.exec_() return False -