diff --git a/components/ErrorDialog.py b/components/ErrorDialog.py new file mode 100644 index 0000000..33b29bf --- /dev/null +++ b/components/ErrorDialog.py @@ -0,0 +1,17 @@ +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton + +class ErrorDialog(QDialog): + def __init__(self, message, parent=None): + super().__init__(parent) + self.setWindowTitle("An error occurred") + self.setGeometry(100,100,400,200) + layout = QVBoxLayout() + + self.label = QLabel(message) + layout.addWidget(self.label) + + self.button = QPushButton("ok") + self.button.clicked.connect(self.accept) # press ok + layout.addWidget(self.button) + + self.setLayout(layout) diff --git a/components/MusicTable.py b/components/MusicTable.py index 40a9b03..b0db049 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -1,12 +1,16 @@ +from mutagen.easyid3 import EasyID3 import DBA from PyQt5.QtGui import QStandardItem, QStandardItemModel, QKeySequence from PyQt5.QtWidgets import QTableView, QShortcut, QMessageBox, QAbstractItemView -from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal +from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, QTimer from utils import add_files_to_library from utils import get_id3_tags from utils import get_album_art +from utils import set_id3_tag import logging import configparser +import os +import shutil class MusicTable(QTableView): @@ -19,8 +23,10 @@ class MusicTable(QTableView): self.setModel(self.model) # Same as above self.config = configparser.ConfigParser() self.config.read('config.ini') - self.headers = ['title', 'artist', 'album', 'genre', 'codec', 'year', 'path'] # gui names of headers - self.columns = str(self.config['table']['columns']).split(',') # db names of headers + 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.vertical_scroll_position = 0 self.songChanged = None self.selected_song_filepath = None self.current_song_filepath = None @@ -32,6 +38,7 @@ class MusicTable(QTableView): self.fetch_library() self.setup_keyboard_shortcuts() self.model.dataChanged.connect(self.on_cell_data_changed) # editing cells + self.model.layoutChanged.connect(self.restore_scroll_position) def setup_keyboard_shortcuts(self): @@ -47,10 +54,11 @@ class MusicTable(QTableView): filepath_index = self.model.index(topLeft.row(), filepath_column_idx) # exact index of the edited cell filepath = self.model.data(filepath_index) # filepath # update the ID3 information - new_data = topLeft.data() - edited_column = self.columns[topLeft.column()] - print(filepath) - print(edited_column) + user_input_data = topLeft.data() + edited_column_name = self.database_columns[topLeft.column()] + response = set_id3_tag(filepath, edited_column_name, user_input_data) + if response: + update_song_in_library(filepath, edited_column_name, user_input_data) def reorganize_selected_files(self): @@ -60,12 +68,29 @@ class MusicTable(QTableView): # Confirmation screen (yes, no) reply = QMessageBox.question(self, 'Confirmation', 'Are you sure you want to reorganize these files?', QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) if reply: - target_dir = str(self.config['directories']['reorganize_destination']) - print(target_dir) - # Get target directory - # Copy files to new dir - # delete files from current dir + target_dir = str(self.config['directories']['reorganize_destination']) + for filepath in filepaths: + try: + # Read file metadata + audio = EasyID3(filepath) + artist = audio.get('artist', ['Unknown Artist'])[0] + album = audio.get('album', ['Unknown Album'])[0] + # Determine the new path that needs to be made + new_path = os.path.join(target_dir, artist, album, os.path.basename(filepath)) + # Create the directories if they dont exist + os.makedirs(os.path.dirname(new_path), exist_ok=True) + # Move the file to the new directory + shutil.move(filepath, new_path) + # Update the db? + with DBA.DBAccess() as db: + db.query('UPDATE library SET filepath = ? WHERE filepath = ?', (new_path, filepath)) + print(f'Moved: {filepath} -> {new_path}') + except Exception as e: + print(f'Error moving file: {filepath} | {e}') + + self.fetch_library() + QMessageBox.information(self, 'Reorganization complete', 'Files successfully reorganized') # add new files to library @@ -102,16 +127,17 @@ class MusicTable(QTableView): def set_selected_song_filepath(self): """Sets the filepath of the currently selected song""" - self.selected_song_filepath = self.currentIndex().siblingAtColumn(self.headers.index('path')).data() + self.selected_song_filepath = self.currentIndex().siblingAtColumn(self.table_headers.index('path')).data() print(f'Selected song: {self.selected_song_filepath}') + # print(get_id3_tags(self.selected_song_filepath)) 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.headers.index('path')).data() - print(f'Current song: {self.current_song_filepath}') + self.current_song_filepath = self.currentIndex().siblingAtColumn(self.table_headers.index('path')).data() + # print(f'Current song: {self.current_song_filepath}') def get_selected_rows(self): @@ -129,7 +155,7 @@ class MusicTable(QTableView): selected_rows = self.get_selected_rows() filepaths = [] for row in selected_rows: - idx = self.model.index(row, self.headers.index('path')) + idx = self.model.index(row, self.table_headers.index('path')) filepaths.append(idx.data()) return filepaths @@ -161,7 +187,9 @@ class MusicTable(QTableView): def fetch_library(self): """Initialize the tableview model""" - self.model.setHorizontalHeaderLabels(self.headers) + self.vertical_scroll_position = self.verticalScrollBar().value() # Get my scroll position before clearing + self.model.clear() + self.model.setHorizontalHeaderLabels(self.table_headers) # Fetch library data with DBA.DBAccess() as db: data = db.query('SELECT title, artist, album, genre, codec, album_date, filepath FROM library;', ()) @@ -170,7 +198,13 @@ class MusicTable(QTableView): items = [QStandardItem(str(item)) for item in row_data] self.model.appendRow(items) # Update the viewport/model - self.viewport().update() + # self.viewport().update() + self.model.layoutChanged.emit() + + def restore_scroll_position(self): + """Restores the scroll position""" + print(f'Returning to {self.vertical_scroll_position}') + QTimer.singleShot(100, lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position)) def add_files(self, files): diff --git a/components/__init__.py b/components/__init__.py index abe2515..46a0b8d 100644 --- a/components/__init__.py +++ b/components/__init__.py @@ -1,3 +1,4 @@ from .MusicTable import MusicTable from .AudioVisualizer import AudioVisualizer from .PreferencesWindow import PreferencesWindow +from .ErrorDialog import ErrorDialog diff --git a/main.py b/main.py index a086aae..cd0696c 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,8 @@ 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 @@ -248,10 +250,15 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): if __name__ == "__main__": - import sys + # Allow for dynamic imports of my custom classes and utilities + project_root = os.path.abspath(os.path.dirname(__file__)) + sys.path.append(project_root) + # Start the app app = QApplication(sys.argv) print(f'main.py app: {app}') + # Dark theme >:3 qdarktheme.setup_theme() + # Show the UI ui = ApplicationWindow(app) ui.show() sys.exit(app.exec_()) diff --git a/utils/__init__.py b/utils/__init__.py index 5760804..822bebc 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,7 +1,8 @@ from .safe_get import safe_get from .get_album_art import get_album_art from .get_id3_tags import get_id3_tags +from .set_id3_tag import set_id3_tag from .initialize_library_database import initialize_library_database from .scan_for_music import scan_for_music from .fft_analyser import FFTAnalyser -from .add_files_to_library import add_files_to_library \ No newline at end of file +from .add_files_to_library import add_files_to_library diff --git a/utils/add_files_to_library.py b/utils/add_files_to_library.py index 0f1b8b8..1de37f6 100644 --- a/utils/add_files_to_library.py +++ b/utils/add_files_to_library.py @@ -21,7 +21,8 @@ def add_files_to_library(files): if any(filepath.lower().endswith(ext) for ext in extensions): filename = filepath.split("/")[-1] audio = get_id3_tags(filepath) - if "title" not in audio: + if "title" not in audio: # This should never run + # get_id3_tags sets the title when no tags exist return False # Append data tuple to insert_data list insert_data.append( diff --git a/utils/create_library.sql b/utils/create_library.sql index 58d8f93..34ed8ea 100644 --- a/utils/create_library.sql +++ b/utils/create_library.sql @@ -12,4 +12,4 @@ CREATE TABLE library( album_date date, bitrate int, date_added TIMESTAMP default CURRENT_TIMESTAMP -); \ No newline at end of file +); diff --git a/utils/get_id3_tags.py b/utils/get_id3_tags.py index 31d15e8..da79c01 100644 --- a/utils/get_id3_tags.py +++ b/utils/get_id3_tags.py @@ -1,5 +1,6 @@ from mutagen.easyid3 import EasyID3 from mutagen import File +import os def get_id3_tags(file): @@ -15,7 +16,13 @@ def get_id3_tags(file): # Check if all tags are empty tags_are_empty = all(not values for values in audio.values()) if tags_are_empty: - audio['title'] = [file.split('/')[-1]] + # split on / to get just the filename + # os.path.splitext to get name without extension + audio['title'] = [os.path.splitext(file.split('/')[-1])[0]] + if audio['title'] is None: # I guess a song could have other tags + # without a title, so i make sure to have title + audio['title'] = [os.path.splitext(file.split('/')[-1])[0]] + audio.save() return audio except Exception as e: print(f"Error: {e}") diff --git a/utils/set_id3_tag.py b/utils/set_id3_tag.py new file mode 100644 index 0000000..8996524 --- /dev/null +++ b/utils/set_id3_tag.py @@ -0,0 +1,88 @@ +import logging +from components.ErrorDialog import ErrorDialog +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 +) + +id3_tag_mapping = { + "title": TIT2, # Title/song name/content description + "artist": TPE1, # Lead performer(s)/Soloist(s) + "album": TALB, # Album/Movie/Show title + "album_artist": TPE2, # Band/orchestra/accompaniment + "genre": TCON, # Content type + "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 + "part_of_a_set": TPOS, # Part of a set + "comments": COMM, # Comments + "copyright": TCOP, # Copyright message + "url_artist": WOAR, # Official artist/performer webpage + "encoded_by": TENC, # Encoded by + "bpm": TBPM, # BPM (beats per minute) + "initial_key": TKEY, # Initial key + "time": TIME, # Time + "encoding_settings": TSSE, # Software/Hardware and settings used for encoding + "original_artist": TOPE, # Original artist(s)/performer(s) + "lyricist": TEXT, # Lyricist/Text writer + "original_lyricist": TOLY, # Original lyricist(s)/text writer(s) + "original_release_year": TORY, # Original release year + "publisher": TPUB, # Publisher + "commercial_info": WCOM, # Commercial information + "copyright_info": WCOP, # Copyright information + "official_audio_source_url": WOAS, # Official audio source webpage + "official_internet_radio_station_homepage": WORS, # Official Internet radio station homepage + "payment_url": WPAY, # Payment (URL) + "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 + + Args: + filepath: path to the mp3 file + tag_name: common name of the ID3 tag + value: valut to set for the tag + + Returns: + True / False""" + + try: + try: # Load existing tags + audio_file = MP3(filepath, ID3=ID3) + 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 + 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 + else: + dialog = ErrorDialog(f'ID3 tag not supported.\nTag: {tag_name}\nTag class: {tag_class}\nValue:{value}') + dialog.exec_() + return False + else: + dialog = ErrorDialog(f'Invalid ID3 tag. Tag: {tag_name}, Value:{value}') + dialog.exec_() + return False + + audio_file.save() + return True + + except Exception as e: + dialog = ErrorDialog(f'An unhandled exception occurred:\n{e}') + dialog.exec_() + return False +