id3 tag date fix

This commit is contained in:
billypom on debian 2024-05-05 13:59:34 -04:00
parent b9c045574c
commit b962ffd583
12 changed files with 543 additions and 179 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@ -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

View File

@ -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

View File

@ -1,4 +1,5 @@
from .MusicTable import MusicTable
from .AlbumArtGraphicsView import AlbumArtGraphicsView
from .AudioVisualizer import AudioVisualizer
from .PreferencesWindow import PreferencesWindow
from .ErrorDialog import ErrorDialog

248
main.py
View File

@ -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

36
show_id3_tags.py Normal file
View File

@ -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()

7
ui.py
View File

@ -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

10
ui.ui
View File

@ -26,7 +26,7 @@
<enum>QLayout::SetFixedSize</enum>
</property>
<item>
<widget class="QGraphicsView" name="albumGraphicsView">
<widget class="AlbumArtGraphicsView" name="albumGraphicsView">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
@ -45,6 +45,9 @@
<height>16777215</height>
</size>
</property>
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
<property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
@ -334,6 +337,11 @@
<extends>QTableView</extends>
<header>components</header>
</customwidget>
<customwidget>
<class>AlbumArtGraphicsView</class>
<extends>QGraphicsView</extends>
<header>components</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>

View File

@ -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

View File

@ -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 {}
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()

View File

@ -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)

View File

@ -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