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
+
+ AlbumArtGraphicsView
+ QGraphicsView
+
+
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
-