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 from mutagen.easyid3 import EasyID3
import DBA 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.QtWidgets import QTableView, QShortcut, QMessageBox, QAbstractItemView
from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, QTimer from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, QTimer
from utils import add_files_to_library from utils import add_files_to_library
@ -17,16 +23,37 @@ import shutil
class MusicTable(QTableView): class MusicTable(QTableView):
playPauseSignal = pyqtSignal() playPauseSignal = pyqtSignal()
enterKey = pyqtSignal() enterKey = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
# QTableView.__init__(self, parent) # QTableView.__init__(self, parent)
super().__init__(parent) super().__init__(parent)
self.model = QStandardItemModel(self) # Necessary for actions related to cell values self.model = QStandardItemModel(self)
self.setModel(self.model) # Same as above # Necessary for actions related to cell values
self.setModel(self.model) # Same as above
self.config = configparser.ConfigParser() self.config = configparser.ConfigParser()
self.config.read('config.ini') self.config.read("config.ini")
self.table_headers = ['title', 'artist', 'album', 'genre', 'codec', 'year', 'path'] # gui names of headers # gui names of headers
self.id3_headers = ['title', 'artist', 'album', 'content_type', ] self.table_headers = [
self.database_columns = str(self.config['table']['columns']).split(',') # db names of 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.vertical_scroll_position = 0
self.songChanged = None self.songChanged = None
self.selected_song_filepath = None self.selected_song_filepath = None
@ -38,24 +65,21 @@ class MusicTable(QTableView):
self.enterKey.connect(self.set_current_song_filepath) self.enterKey.connect(self.set_current_song_filepath)
self.fetch_library() self.fetch_library()
self.setup_keyboard_shortcuts() 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) self.model.layoutChanged.connect(self.restore_scroll_position)
def dragEnterEvent(self, event: QDragEnterEvent): def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
event.accept() event.accept()
else: else:
event.ignore() event.ignore()
def dragMoveEvent(self, event: QDragEnterEvent): def dragMoveEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
event.accept() event.accept()
else: else:
event.ignore() event.ignore()
def dropEvent(self, event: QDropEvent): def dropEvent(self, event: QDropEvent):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
files = [] files = []
@ -67,103 +91,122 @@ class MusicTable(QTableView):
else: else:
event.ignore() event.ignore()
def setup_keyboard_shortcuts(self): def setup_keyboard_shortcuts(self):
"""Setup shortcuts here""" """Setup shortcuts here"""
shortcut = QShortcut(QKeySequence("Ctrl+Shift+R"), self) shortcut = QShortcut(QKeySequence("Ctrl+Shift+R"), self)
shortcut.activated.connect(self.reorganize_selected_files) shortcut.activated.connect(self.reorganize_selected_files)
def on_cell_data_changed(self, topLeft: QModelIndex, bottomRight: QModelIndex): def on_cell_data_changed(self, topLeft: QModelIndex, bottomRight: QModelIndex):
"""Handles updating ID3 tags when data changes in a cell""" """Handles updating ID3 tags when data changes in a cell"""
print('on_cell_data_changed') print("on_cell_data_changed")
id_index = self.model.index(topLeft.row(), 0) # ID is column 0, always id_index = self.model.index(topLeft.row(), 0) # ID is column 0, always
library_id = self.model.data(id_index, Qt.UserRole) library_id = self.model.data(id_index, Qt.UserRole)
filepath_column_idx = self.model.columnCount() - 1 # filepath is always the last column # 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_column_idx = self.model.columnCount() - 1
filepath = self.model.data(filepath_index) # filepath 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 # update the ID3 information
user_input_data = topLeft.data() user_input_data = topLeft.data()
edited_column_name = self.database_columns[topLeft.column()] 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) response = set_id3_tag(filepath, edited_column_name, user_input_data)
if response: if response:
# Update the library with new metadata # Update the library with new metadata
update_song_in_library(library_id, edited_column_name, user_input_data) update_song_in_library(library_id, edited_column_name, user_input_data)
def reorganize_selected_files(self): def reorganize_selected_files(self):
"""Ctrl+Shift+R = Reorganize""" """Ctrl+Shift+R = Reorganize"""
filepaths = self.get_selected_songs_filepaths() filepaths = self.get_selected_songs_filepaths()
# Confirmation screen (yes, no) # 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: if reply:
# Get target directory # Get target directory
target_dir = str(self.config['directories']['reorganize_destination']) target_dir = str(self.config["directories"]["reorganize_destination"])
for filepath in filepaths: for filepath in filepaths:
try: try:
# Read file metadata # Read file metadata
audio = EasyID3(filepath) audio = EasyID3(filepath)
artist = audio.get('artist', ['Unknown Artist'])[0] artist = audio.get("artist", ["Unknown Artist"])[0]
album = audio.get('album', ['Unknown Album'])[0] album = audio.get("album", ["Unknown Album"])[0]
# Determine the new path that needs to be made # 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 # Create the directories if they dont exist
os.makedirs(os.path.dirname(new_path), exist_ok=True) os.makedirs(os.path.dirname(new_path), exist_ok=True)
# Move the file to the new directory # Move the file to the new directory
shutil.move(filepath, new_path) shutil.move(filepath, new_path)
# Update the db # Update the db
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
db.query('UPDATE library SET filepath = ? WHERE filepath = ?', (new_path, filepath)) db.query(
print(f'Moved: {filepath} -> {new_path}') "UPDATE library SET filepath = ? WHERE filepath = ?",
(new_path, filepath),
)
print(f"Moved: {filepath} -> {new_path}")
except Exception as e: except Exception as e:
print(f'Error moving file: {filepath} | {e}') print(f"Error moving file: {filepath} | {e}")
# Draw the rest of the owl # Draw the rest of the owl
self.model.dataChanged.disconnect(self.on_cell_data_changed) self.model.dataChanged.disconnect(self.on_cell_data_changed)
self.fetch_library() self.fetch_library()
self.model.dataChanged.connect(self.on_cell_data_changed) 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): def keyPressEvent(self, event):
"""Press a key. Do a thing""" """Press a key. Do a thing"""
key = event.key() 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() self.toggle_play_pause()
elif key == Qt.Key_Up: # Arrow key navigation elif key == Qt.Key_Up: # Arrow key navigation
current_index = self.currentIndex() 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(): if new_index.isValid():
self.setCurrentIndex(new_index) self.setCurrentIndex(new_index)
elif key == Qt.Key_Down: # Arrow key navigation elif key == Qt.Key_Down: # Arrow key navigation
current_index = self.currentIndex() 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(): if new_index.isValid():
self.setCurrentIndex(new_index) self.setCurrentIndex(new_index)
elif key in (Qt.Key_Return, Qt.Key_Enter): elif key in (Qt.Key_Return, Qt.Key_Enter):
if self.state() != QAbstractItemView.EditingState: if self.state() != QAbstractItemView.EditingState:
self.enterKey.emit() # Enter key detected self.enterKey.emit() # Enter key detected
else: else:
super().keyPressEvent(event) super().keyPressEvent(event)
else: # Default behavior else: # Default behavior
super().keyPressEvent(event) super().keyPressEvent(event)
def toggle_play_pause(self): def toggle_play_pause(self):
"""Toggles the currently playing song by emitting a signal""" """Toggles the currently playing song by emitting a signal"""
if not self.current_song_filepath: if not self.current_song_filepath:
self.set_current_song_filepath() self.set_current_song_filepath()
self.playPauseSignal.emit() self.playPauseSignal.emit()
def fetch_library(self): def fetch_library(self):
"""Initialize the tableview model""" """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 # temporarily disconnect the datachanged signal to avoid EVERY SONG getting triggered
self.model.clear() self.model.clear()
self.model.setHorizontalHeaderLabels(self.table_headers) self.model.setHorizontalHeaderLabels(self.table_headers)
# Fetch library data # Fetch library data
with DBA.DBAccess() as db: 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 # Populate the model
for row_data in data: for row_data in data:
id, *rest_of_data = row_data id, *rest_of_data = row_data
@ -174,16 +217,16 @@ class MusicTable(QTableView):
for item in items: for item in items:
item.setData(id, Qt.UserRole) item.setData(id, Qt.UserRole)
# Update the viewport/model # 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) -> None:
def restore_scroll_position(self):
"""Restores the scroll position""" """Restores the scroll position"""
print(f'Returning to {self.vertical_scroll_position}') QTimer.singleShot(
QTimer.singleShot(100, lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position)) 100,
lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position),
)
def add_files(self, files) -> None:
def add_files(self, files):
"""When song(s) added to the library, update the tableview model """When song(s) added to the library, update the tableview model
- Drag & Drop song(s) on tableView - Drag & Drop song(s) on tableView
- File > Open > List of song(s) - File > Open > List of song(s)
@ -194,62 +237,54 @@ class MusicTable(QTableView):
self.fetch_library() self.fetch_library()
self.model.dataChanged.connect(self.on_cell_data_changed) self.model.dataChanged.connect(self.on_cell_data_changed)
def get_selected_rows(self) -> list[int]:
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):
"""Returns a list of indexes for every selected row""" """Returns a list of indexes for every selected row"""
selection_model = self.selectionModel() selection_model = self.selectionModel()
return [index.row() for index in selection_model.selectedRows()] return [index.row() for index in selection_model.selectedRows()]
def get_selected_songs_filepaths(self) -> list[str]:
def get_selected_songs_filepaths(self):
"""Returns a list of the filepaths for the currently selected songs""" """Returns a list of the filepaths for the currently selected songs"""
selected_rows = self.get_selected_rows() selected_rows = self.get_selected_rows()
filepaths = [] filepaths = []
for row in selected_rows: 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()) filepaths.append(idx.data())
return filepaths return filepaths
def get_selected_song_filepath(self) -> str:
def get_selected_song_filepath(self):
"""Returns the selected songs filepath""" """Returns the selected songs filepath"""
return self.selected_song_filepath return self.selected_song_filepath
def get_selected_song_metadata(self) -> dict:
def get_selected_song_metadata(self):
"""Returns the selected song's ID3 tags""" """Returns the selected song's ID3 tags"""
return get_id3_tags(self.selected_song_filepath) return get_id3_tags(self.selected_song_filepath)
def get_current_song_filepath(self) -> str:
def get_current_song_filepath(self):
"""Returns the currently playing song filepath""" """Returns the currently playing song filepath"""
return self.current_song_filepath return self.current_song_filepath
def get_current_song_metadata(self) -> dict:
def get_current_song_metadata(self):
"""Returns the currently playing song's ID3 tags""" """Returns the currently playing song's ID3 tags"""
return get_id3_tags(self.current_song_filepath) return get_id3_tags(self.current_song_filepath)
def get_current_song_album_art(self) -> None:
def get_current_song_album_art(self):
"""Returns the APIC data (album art lol) for the currently playing song""" """Returns the APIC data (album art lol) for the currently playing song"""
return get_album_art(self.current_song_filepath) 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): def set_current_song_filepath(self) -> None:
# why was this necessary again? :thinking: """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 self.qapp = qapp

View File

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

248
main.py
View File

@ -1,7 +1,18 @@
from ui import Ui_MainWindow import os
from PyQt5.QtWidgets import QMainWindow, QApplication, QGraphicsScene, \ import configparser
QHeaderView, QGraphicsPixmapItem import sys
import qdarktheme 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.QtCore import QUrl, QTimer, QEvent, Qt, QModelIndex
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe
from PyQt5.QtGui import QPixmap, QStandardItemModel 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 utils import delete_and_create_library_database
from components import AudioVisualizer from components import AudioVisualizer
from components import PreferencesWindow from components import PreferencesWindow
from pyqtgraph import mkBrush
import configparser
import os
import sys
# Create ui.py file from Qt Designer # Create ui.py file from Qt Designer
# pyuic5 ui.ui -o ui.py # pyuic5 ui.ui -o ui.py
@ -22,7 +29,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def __init__(self, qapp): def __init__(self, qapp):
super(ApplicationWindow, self).__init__() super(ApplicationWindow, self).__init__()
self.setupUi(self) self.setupUi(self)
self.setWindowTitle('MusicPom') self.setWindowTitle("MusicPom")
self.selected_song_filepath = None self.selected_song_filepath = None
self.current_song_filepath = None self.current_song_filepath = None
self.current_song_metadata = None self.current_song_metadata = None
@ -31,9 +38,10 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.qapp = qapp self.qapp = qapp
# print(f'ApplicationWindow self.qapp: {self.qapp}') # print(f'ApplicationWindow self.qapp: {self.qapp}')
self.tableView.load_qapp(self.qapp) self.tableView.load_qapp(self.qapp)
self.albumGraphicsView.load_qapp(self.qapp)
# print(f'tableView type: {type(self.tableView)}') # print(f'tableView type: {type(self.tableView)}')
self.config = configparser.ConfigParser() self.config = configparser.ConfigParser()
self.config.read('config.ini') self.config.read("config.ini")
global stopped global stopped
stopped = False stopped = False
# Initialization # Initialization
@ -48,74 +56,111 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
# Provides faster updates than move_slider # Provides faster updates than move_slider
self.probe.setSource(self.player) self.probe.setSource(self.player)
self.probe.audioBufferProbed.connect(self.process_probe) self.probe.audioBufferProbed.connect(self.process_probe)
# Slider Timer (realtime playback feedback horizontal bar) # 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) self.timer.timeout.connect(self.move_slider)
# Graphics plot # Graphics plot
self.PlotWidget.setXRange(0,100,padding=0) # x axis range self.PlotWidget.setXRange(0, 100, padding=0) # x axis range
self.PlotWidget.setYRange(0,0.3,padding=0) # y 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").setTicks([]) # Remove x-axis ticks
self.PlotWidget.getAxis('bottom').setLabel('') # Remove x-axis label self.PlotWidget.getAxis("bottom").setLabel("") # Remove x-axis label
self.PlotWidget.setLogMode(False, False) self.PlotWidget.setLogMode(False, False)
# Remove y-axis labels and decorations # Remove y-axis labels and decorations
self.PlotWidget.getAxis('left').setTicks([]) # Remove y-axis ticks self.PlotWidget.getAxis("left").setTicks([]) # Remove y-axis ticks
self.PlotWidget.getAxis('left').setLabel('') # Remove y-axis label self.PlotWidget.getAxis("left").setLabel("") # Remove y-axis label
# Connections # Connections
# ! FIXME moving the slider while playing is happening frequently causes playback to halt - the pyqtgraph is also affected by this # ! 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.playbackSlider.sliderMoved[int].connect(
self.volumeSlider.sliderMoved[int].connect(lambda: self.volume_changed()) # Move slider to adjust volume lambda: self.player.setPosition(self.playbackSlider.value())
self.playButton.clicked.connect(self.on_play_clicked) # Click to play/pause ) # Move slidet to adjust playback time
self.previousButton.clicked.connect(self.on_previous_clicked) # Click to previous song self.volumeSlider.sliderMoved[int].connect(
self.nextButton.clicked.connect(self.on_next_clicked) # Click to next song lambda: self.volume_changed()
self.actionPreferences.triggered.connect(self.actionPreferencesClicked) # Open preferences menu ) # Move slider to adjust volume
self.actionScanLibraries.triggered.connect(self.scan_libraries) # Scan library self.playButton.clicked.connect(self.on_play_clicked) # Click to play/pause
self.actionClearDatabase.triggered.connect(self.clear_database) # Clear database 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 ## tableView
self.tableView.doubleClicked.connect(self.play_audio_file) # Listens for the double click event, then plays the song self.tableView.doubleClicked.connect(
self.tableView.enterKey.connect(self.play_audio_file) # Listens for the enter key event, then plays the song self.play_audio_file
self.tableView.playPauseSignal.connect(self.on_play_clicked) # Spacebar toggle play/pause signal ) # Listens for the double click event, then plays the song
self.tableView.viewport().installEventFilter(self) # for drag & drop functionality 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 # 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()): for i in range(self.tableView.model.columnCount()):
self.tableView.setColumnWidth(i, int(table_view_column_widths[i])) self.tableView.setColumnWidth(i, int(table_view_column_widths[i]))
# dont extend last column past table view border # dont extend last column past table view border
self.tableView.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.tableView.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.tableView.horizontalHeader().setStretchLastSection(False) self.tableView.horizontalHeader().setStretchLastSection(False)
def closeEvent(self, event) -> None:
def closeEvent(self, event):
"""Save settings when closing the application""" """Save settings when closing the application"""
# MusicTable/tableView column widths # MusicTable/tableView column widths
list_of_column_widths = [] list_of_column_widths = []
for i in range(self.tableView.model.columnCount()): for i in range(self.tableView.model.columnCount()):
list_of_column_widths.append(str(self.tableView.columnWidth(i))) list_of_column_widths.append(str(self.tableView.columnWidth(i)))
column_widths_as_string = ','.join(list_of_column_widths) column_widths_as_string = ",".join(list_of_column_widths)
self.config['table']['column_widths'] = column_widths_as_string self.config["table"]["column_widths"] = column_widths_as_string
# Save the config # Save the config
with open('config.ini', 'w') as configfile: with open("config.ini", "w") as configfile:
self.config.write(configfile) self.config.write(configfile)
super().closeEvent(event) super().closeEvent(event)
def play_audio_file(self) -> None:
def play_audio_file(self):
"""Start playback of tableView.current_song_filepath track & moves playback slider""" """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() self.current_song_album_art = self.tableView.get_current_song_album_art()
url = QUrl.fromLocalFile(self.tableView.get_current_song_filepath()) # read the file url = QUrl.fromLocalFile(
content = QMediaContent(url) # load the audio content self.tableView.get_current_song_filepath()
self.player.setMedia(content) # what content to play ) # read the file
self.player.play() # play content = QMediaContent(url) # load the audio content
self.move_slider() # mover self.player.setMedia(content) # what content to play
self.player.play() # play
self.move_slider() # mover
# assign metadata # assign metadata
# FIXME when i change tinytag to something else # FIXME when i change tinytag to something else
artist = self.current_song_metadata["artist"][0] if "artist" in self.current_song_metadata else None artist = (
album = self.current_song_metadata["album"][0] if "album" in self.current_song_metadata else None 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] title = self.current_song_metadata["title"][0]
# edit labels # edit labels
self.artistLabel.setText(artist) self.artistLabel.setText(artist)
@ -124,19 +169,24 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
# set album artwork # set album artwork
self.load_album_art(self.current_song_album_art) 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""" """Displays the album art for the currently playing track in the GraphicsView"""
if self.current_song_album_art: if self.current_song_album_art:
# Clear the scene # Clear the scene
self.album_art_scene.clear() try:
self.album_art_scene.clear()
except Exception:
pass
# Reset the scene # Reset the scene
self.albumGraphicsView.setScene(None) self.albumGraphicsView.setScene(None)
# Create pixmap for album art # Create pixmap for album art
pixmap = QPixmap() pixmap = QPixmap()
pixmap.loadFromData(self.current_song_album_art) pixmap.loadFromData(album_art_data)
# Create a QGraphicsPixmapItem for more control over pic # Create a QGraphicsPixmapItem for more control over pic
pixmapItem = QGraphicsPixmapItem(pixmap) 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 # Add pixmap item to the scene
self.album_art_scene.addItem(pixmapItem) self.album_art_scene.addItem(pixmapItem)
# Set the scene # Set the scene
@ -144,32 +194,82 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
# Adjust the album art scaling # Adjust the album art scaling
self.adjustPixmapScaling(pixmapItem) 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""" """Adjust the scaling of the pixmap item to fit the QGraphicsView, maintaining aspect ratio"""
viewWidth = self.albumGraphicsView.width() viewWidth = self.albumGraphicsView.width()
viewHeight = self.albumGraphicsView.height() viewHeight = self.albumGraphicsView.height()
pixmapSize = pixmapItem.pixmap().size() pixmapSize = pixmapItem.pixmap().size()
# Calculate scaling factor while maintaining aspect ratio # Calculate scaling factor while maintaining aspect ratio
scaleX = viewWidth / pixmapSize.width() scaleX = viewWidth / pixmapSize.width()
scaleY = viewHeight / pixmapSize.height() scaleY = viewHeight / pixmapSize.height()
scaleFactor = min(scaleX, scaleY) scaleFactor = min(scaleX, scaleY)
# Apply scaling to the pixmap item # Apply scaling to the pixmap item
pixmapItem.setScale(scaleFactor) 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""" """Handles upading points on the pyqtgraph visual"""
self.clear_audio_visualization() self.clear_audio_visualization()
self.y = self.audio_visualizer.get_amplitudes() self.y = self.audio_visualizer.get_amplitudes()
self.x = [i for i in range(len(self.y))] 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() self.PlotWidget.show()
def clear_audio_visualization(self): def clear_audio_visualization(self) -> None:
self.PlotWidget.clear() self.PlotWidget.clear()
def move_slider(self): def move_slider(self) -> None:
"""Handles moving the playback slider""" """Handles moving the playback slider"""
if stopped: if stopped:
return return
@ -180,14 +280,18 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.playbackSlider.setMaximum(self.player.duration()) self.playbackSlider.setMaximum(self.player.duration())
slider_position = self.player.position() slider_position = self.player.position()
self.playbackSlider.setValue(slider_position) self.playbackSlider.setValue(slider_position)
current_minutes, current_seconds = divmod(slider_position / 1000, 60) current_minutes, current_seconds = divmod(slider_position / 1000, 60)
duration_minutes, duration_seconds = divmod(self.player.duration() / 1000, 60) 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}") def volume_changed(self) -> None:
self.endTimeLabel.setText(f"{int(duration_minutes):02d}:{int(duration_seconds):02d}")
def volume_changed(self):
"""Handles volume changes""" """Handles volume changes"""
try: try:
self.current_volume = self.volumeSlider.value() self.current_volume = self.volumeSlider.value()
@ -195,7 +299,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
except Exception as e: except Exception as e:
print(f"Changing volume error: {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""" """Updates the Play & Pause buttons when clicked"""
if self.player.state() == QMediaPlayer.PlayingState: if self.player.state() == QMediaPlayer.PlayingState:
self.player.pause() self.player.pause()
@ -208,26 +312,26 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.play_audio_file() self.play_audio_file()
self.playButton.setText("⏸️") self.playButton.setText("⏸️")
def on_previous_clicked(self): def on_previous_clicked(self) -> None:
"""""" """"""
print('previous') print("previous")
def on_next_clicked(self): def on_next_clicked(self) -> None:
print('next') print("next")
def actionPreferencesClicked(self): def actionPreferencesClicked(self) -> None:
preferences_window = PreferencesWindow(self.config) preferences_window = PreferencesWindow(self.config)
preferences_window.exec_() # Display the preferences window modally preferences_window.exec_() # Display the preferences window modally
def scan_libraries(self): def scan_libraries(self) -> None:
scan_for_music() scan_for_music()
self.tableView.fetch_library() self.tableView.fetch_library()
def clear_database(self): def clear_database(self) -> None:
delete_and_create_library_database() delete_and_create_library_database()
self.tableView.fetch_library() self.tableView.fetch_library()
def process_probe(self, buff): def process_probe(self, buff) -> None:
buff.startTime() buff.startTime()
self.update_audio_visualization() self.update_audio_visualization()
@ -238,7 +342,7 @@ if __name__ == "__main__":
sys.path.append(project_root) sys.path.append(project_root)
# Start the app # Start the app
app = QApplication(sys.argv) app = QApplication(sys.argv)
print(f'main.py app: {app}') print(f"main.py app: {app}")
# Dark theme >:3 # Dark theme >:3
qdarktheme.setup_theme() qdarktheme.setup_theme()
# Show the UI # 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 = QtWidgets.QVBoxLayout()
self.vlayoutAlbumArt.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) self.vlayoutAlbumArt.setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
self.vlayoutAlbumArt.setObjectName("vlayoutAlbumArt") 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 = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
@ -33,6 +33,7 @@ class Ui_MainWindow(object):
self.albumGraphicsView.setSizePolicy(sizePolicy) self.albumGraphicsView.setSizePolicy(sizePolicy)
self.albumGraphicsView.setMinimumSize(QtCore.QSize(200, 200)) self.albumGraphicsView.setMinimumSize(QtCore.QSize(200, 200))
self.albumGraphicsView.setMaximumSize(QtCore.QSize(16777215, 16777215)) self.albumGraphicsView.setMaximumSize(QtCore.QSize(16777215, 16777215))
self.albumGraphicsView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.albumGraphicsView.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.albumGraphicsView.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.albumGraphicsView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.albumGraphicsView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.albumGraphicsView.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustIgnored) self.albumGraphicsView.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustIgnored)
@ -153,7 +154,7 @@ class Ui_MainWindow(object):
self.verticalLayout_3.setStretch(3, 1) self.verticalLayout_3.setStretch(3, 1)
MainWindow.setCentralWidget(self.centralwidget) MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow) 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.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(self.menubar) self.menuFile = QtWidgets.QMenu(self.menubar)
self.menuFile.setObjectName("menuFile") self.menuFile.setObjectName("menuFile")
@ -204,5 +205,5 @@ class Ui_MainWindow(object):
self.actionPreferences.setStatusTip(_translate("MainWindow", "Open preferences")) self.actionPreferences.setStatusTip(_translate("MainWindow", "Open preferences"))
self.actionScanLibraries.setText(_translate("MainWindow", "Scan libraries")) self.actionScanLibraries.setText(_translate("MainWindow", "Scan libraries"))
self.actionClearDatabase.setText(_translate("MainWindow", "Clear Database")) self.actionClearDatabase.setText(_translate("MainWindow", "Clear Database"))
from components import MusicTable from components import AlbumArtGraphicsView, MusicTable
from pyqtgraph import PlotWidget from pyqtgraph import PlotWidget

10
ui.ui
View File

@ -26,7 +26,7 @@
<enum>QLayout::SetFixedSize</enum> <enum>QLayout::SetFixedSize</enum>
</property> </property>
<item> <item>
<widget class="QGraphicsView" name="albumGraphicsView"> <widget class="AlbumArtGraphicsView" name="albumGraphicsView">
<property name="sizePolicy"> <property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum"> <sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch> <horstretch>0</horstretch>
@ -45,6 +45,9 @@
<height>16777215</height> <height>16777215</height>
</size> </size>
</property> </property>
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
<property name="verticalScrollBarPolicy"> <property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum> <enum>Qt::ScrollBarAlwaysOff</enum>
</property> </property>
@ -334,6 +337,11 @@
<extends>QTableView</extends> <extends>QTableView</extends>
<header>components</header> <header>components</header>
</customwidget> </customwidget>
<customwidget>
<class>AlbumArtGraphicsView</class>
<extends>QGraphicsView</extends>
<header>components</header>
</customwidget>
</customwidgets> </customwidgets>
<resources/> <resources/>
<connections/> <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 .scan_for_music import scan_for_music
from .fft_analyser import FFTAnalyser from .fft_analyser import FFTAnalyser
from .add_files_to_library import add_files_to_library 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 """Get the album art for an audio file
# Parameters # Parameters
`file` | str | Fully qualified path to file `file` | str | Fully qualified path to file
# Returns # Returns
dict of all id3 tags Data for album art or default file
""" """
default_image_path = './assets/default_album_art.jpg'
try: try:
audio = ID3(file) audio = ID3(file)
album_art = audio.get('APIC:').data if 'APIC:' in audio else None for tag in audio.getall('APIC'):
return album_art 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: except Exception as e:
print(f"Error: {e}") print(f"Error retrieving album art: {e}")
return {} 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 import logging
from components.ErrorDialog import ErrorDialog 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.id3 import ID3, ID3NoHeaderError, Frame
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from mutagen.id3 import ( from mutagen.id3 import (
TIT2, TPE1, TALB, TRCK, TYER, TCON, TPOS, COMM, TPE2, TCOM, TPE3, TPE4, TIT2,
TCOP, WOAR, USLT, APIC, TENC, TBPM, TKEY, TDAT, TIME, TSSE, TOPE, TEXT, TPE1,
TOLY, TORY, TPUB, WCOM, WCOP, WOAS, WORS, WPAY, WPUB 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 = { id3_tag_mapping = {
@ -15,12 +47,11 @@ id3_tag_mapping = {
"album": TALB, # Album/Movie/Show title "album": TALB, # Album/Movie/Show title
"album_artist": TPE2, # Band/orchestra/accompaniment "album_artist": TPE2, # Band/orchestra/accompaniment
"genre": TCON, # Content type "genre": TCON, # Content type
"year": TYER, # Year of recording # "year": TYER, # Year of recording
"date": TDAT, # Date # "date": TDAT, # Date
"lyrics": USLT, # Unsynchronized lyric/text transcription "lyrics": USLT, # Unsynchronized lyric/text transcription
"track_number": TRCK, # Track number/Position in set "track_number": TRCK, # Track number/Position in set
"album_cover": APIC, # Attached picture "album_cover": APIC, # Attached picture
"composer": TCOM, # Composer "composer": TCOM, # Composer
"conductor": TPE3, # Conductor/performer refinement "conductor": TPE3, # Conductor/performer refinement
"remixed_by": TPE4, # Interpreted, remixed, or otherwise modified by "remixed_by": TPE4, # Interpreted, remixed, or otherwise modified by
@ -46,6 +77,7 @@ id3_tag_mapping = {
"publishers_official_webpage": WPUB, # Publishers official webpage "publishers_official_webpage": WPUB, # Publishers official webpage
} }
def set_id3_tag(filepath: str, tag_name: str, value: str): 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 """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: Returns:
True / False""" True / False"""
print(
f"set_id3_tag(): filepath: {filepath} | tag_name: {tag_name} | value: {value}"
)
try: try:
try: # Load existing tags try: # Load existing tags
audio_file = MP3(filepath, ID3=ID3) 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 = MP3(filepath)
audio_file.add_tags() audio_file.add_tags()
if tag_name == "album_date":
if tag_name in id3_tag_mapping: # Tag accounted for 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] tag_class = id3_tag_mapping[tag_name]
# if issubclass(tag_class, EasyID3) or issubclass(tag_class, ID3): # Type safety # if issubclass(tag_class, EasyID3) or issubclass(tag_class, ID3): # Type safety
if issubclass(tag_class, Frame): 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: else:
dialog = ErrorDialog(f'ID3 tag not supported.\nTag: {tag_name}\nTag class: {tag_class}\nValue:{value}') # dialog = ErrorDialog(f'ID3 tag not supported.\nTag: {tag_name}\nTag class: {tag_class}\nValue:{value}')
dialog.exec_() # dialog.exec_()
return False # return False
pass
else: else:
dialog = ErrorDialog(f'Invalid ID3 tag. Tag: {tag_name}, Value:{value}') # dialog = ErrorDialog(f'Invalid ID3 tag. Tag: {tag_name}, Value:{value}')
dialog.exec_() # dialog.exec_()
return False # return False
pass
audio_file.save() audio_file.save()
print("ID3 tags updated:")
print(get_id3_tags(filepath))
print("-----")
return True return True
except Exception as e: except Exception as e:
dialog = ErrorDialog(f'An unhandled exception occurred:\n{e}') dialog = ErrorDialog(f"An unhandled exception occurred:\n{e}")
dialog.exec_() dialog.exec_()
return False return False