diff --git a/components/EditPlaylistOptionsWindow.py b/components/EditPlaylistOptionsWindow.py index f20d6a1..d595ac5 100644 --- a/components/EditPlaylistOptionsWindow.py +++ b/components/EditPlaylistOptionsWindow.py @@ -16,7 +16,7 @@ class EditPlaylistOptionsWindow(QDialog): # super(EditPlaylistOptionsWindow, self).__init__() super().__init__(parent) self.setWindowTitle("Playlist options") - # self.setMinimumSize(800, 200) + self.setMinimumSize(400, 300) self.playlist_id = playlist_id # self.playlist_path_prefix: str = self.config.get( # "settings", "playlist_path_prefix" @@ -61,7 +61,7 @@ class EditPlaylistOptionsWindow(QDialog): label.setFont(QFont("Sans", weight=QFont.Bold)) layout.addWidget(label) label = QLabel('i.e.: ../music/playlists/my-playlist.m3u') - label.setFont(QFont("Serif", weight=QFont.Style.StyleItalic)) # bold category + label.setFont(QFont("Sans", italic=True)) layout.addWidget(label) # Relative export path line edit widget @@ -73,7 +73,7 @@ class EditPlaylistOptionsWindow(QDialog): label.setFont(QFont("Sans", weight=QFont.Bold)) layout.addWidget(label) label = QLabel("i.e.: /prefix/song.mp3") - label.setFont(QFont("Serif", weight=QFont.Style.StyleItalic)) # bold category + label.setFont(QFont("Sans", italic=True)) layout.addWidget(label) # Playlist file save path line edit widget diff --git a/components/LyricsWindow.py b/components/LyricsWindow.py index 0065c1f..f74df36 100644 --- a/components/LyricsWindow.py +++ b/components/LyricsWindow.py @@ -46,7 +46,7 @@ class LyricsWindow(QDialog): value=self.input_field.toPlainText(), ) if success: - debug("lyrical success! yay") + debug("Lyrics saved successfully") else: error_dialog = ErrorDialog("Could not save lyrics :( sad") error_dialog.exec() diff --git a/components/MusicTable.py b/components/MusicTable.py index 3428b73..403f176 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -38,6 +38,7 @@ from components.QuestionBoxDetails import QuestionBoxDetails from components.HeaderTags import HeaderTags2 from utils import ( + batch_delete_filepaths_from_filesystem, batch_delete_filepaths_from_database, batch_delete_filepaths_from_playlist, add_files_to_database, @@ -315,7 +316,7 @@ class MusicTable(QTableView): if directories: debug('Spawning worker thread for directories') worker = Worker(self.get_audio_files_recursively, directories) - _ = worker.signals.signal_progress.connect(self.handle_progress) + _ = worker.signals.signal_progress.connect(self.qapp.handle_progress) _ = worker.signals.signal_result.connect( self.on_get_audio_files_recursively_finished ) @@ -645,7 +646,7 @@ class MusicTable(QTableView): - Drag & Drop song(s) on tableView - File > Open > List of song(s) """ - debug(f'add_files_to_library() files={files}') + # debug(f'add_files_to_library() files={files}') worker = Worker(add_files_to_database, files, None) _ = worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore _ = worker.signals.signal_result.connect(self.on_add_files_to_database_finished) @@ -678,16 +679,17 @@ class MusicTable(QTableView): """Asks to delete the currently selected songs from the db and music table (not the filesystem)""" # NOTE: provide extra questionbox option? # | Delete from playlist & lib | Delete from playlist only | Cancel | - # Currently, this just has 2 options [the playlist], or [the main lib & all playlists] + # Currently, this just has 2 options [the playlist, cancel], or [the main lib & all playlists, cancel] selected_filepaths = self.get_selected_songs_filepaths() if self.selected_playlist_id: question_dialog = QuestionBoxDetails( title="Delete songs", description="Remove these songs from the playlist?", + choices=[(1, 'Confirm'), (0, 'Cancel')], data=selected_filepaths, ) reply = question_dialog.execute() - if reply: + if reply == 1: worker = Worker( batch_delete_filepaths_from_playlist, selected_filepaths, @@ -701,11 +703,12 @@ class MusicTable(QTableView): else: question_dialog = QuestionBoxDetails( title="Delete songs", - description="Remove these songs from the library?\n(This will remove the songs from all playlists)", + description="Delete these songs from the library?\n(This will remove the songs from all playlists)", + choices=[(1, 'Delete'), (2, 'Delete from filesystem too'), (0, 'Cancel')], data=selected_filepaths, ) reply = question_dialog.execute() - if reply: + if reply == 1 or reply == 2: worker = Worker( batch_delete_filepaths_from_database, selected_filepaths ) @@ -714,21 +717,18 @@ class MusicTable(QTableView): if self.qapp: threadpool = self.qapp.threadpool # type: ignore threadpool.start(worker) + if reply == 2: + def finished(): + self.qapp.handle_progress('Files delete from system.') + worker = Worker( + batch_delete_filepaths_from_filesystem, selected_filepaths + ) + worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore + worker.signals.signal_finished.connect(finished) + if self.qapp: + threadpool = self.qapp.threadpool # type: ignore + threadpool.start(worker) - # def delete_selected_row_indices(self): - # """ - # Removes rows from the QTableView based on a list of indices - # and then reload the table - # """ - # debug('delete_selected_row_indices') - # selected_indices = self.get_selected_rows() - # for index in selected_indices: - # try: - # self.model2.removeRow(index) - # except Exception as e: - # debug(f"delete_selected_row_indices() failed | {e}") - # self.model2.layoutChanged.emit() # emits a signal that the view should be updated - # # self.viewport().update() def delete_selected_row_indices(self): """ @@ -845,7 +845,7 @@ class MusicTable(QTableView): ) if reply == QMessageBox.Yes: worker = Worker(self.reorganize_files, filepaths) - worker.signals.signal_progress.connect(self.handle_progress) + worker.signals.signal_progress.connect(self.qapp.handle_progress) worker.signals.signal_finished.connect(self.load_music_table) self.qapp.threadpool.start(worker) # type: ignore @@ -1128,7 +1128,6 @@ class MusicTable(QTableView): audio_files.append(os.path.join(root, file)) if progress_callback: progress_callback.emit(f"Scanning {file}") - debug(f'get_audio_files_recursively() return: {audio_files}') return audio_files def get_selected_rows(self) -> list[int]: diff --git a/components/QuestionBoxDetails.py b/components/QuestionBoxDetails.py index a426154..80bd41e 100644 --- a/components/QuestionBoxDetails.py +++ b/components/QuestionBoxDetails.py @@ -15,15 +15,30 @@ from pprint import pformat class QuestionBoxDetails(QDialog): - def __init__(self, title: str, description: str, data): + """ + Dialog box primitive that displays arbitrary `data`, and prompts user for a `reply` as a QPushButton + + Args: + - `reply`: An integer representing a user choice based on key of `choices` + - `choices`: A list of tuples, where each tuple represents a valid reply + e.g. [(reply: int, name: str),] + + Notes: + You may supply an arbitrary number of choices, but here are my suggestions: + 2 = The alternative + 1 = The default + 0 = Cancel + -1 = Cancel action (non-response) + """ + def __init__(self, title: str, description: str, data: str | list | dict, choices: list[tuple[int, str]]): super(QuestionBoxDetails, self).__init__() self.title: str = title self.description: str = description - self.data: str = data - self.reply: bool = False + self.data: str | list | dict = data + self.reply: int = -1 self.setWindowTitle(title) - self.setMinimumSize(400, 400) - self.setMaximumSize(600,1000) + self.setMinimumSize(600, 400) + self.setMaximumSize(600, 400) layout = QVBoxLayout() h_layout = QHBoxLayout() @@ -37,10 +52,9 @@ class QuestionBoxDetails(QDialog): else: table: QTableWidget = QTableWidget() table.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) - table.horizontalHeader().setStretchLastSection(True) - table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + table.horizontalHeader().setStretchLastSection(True) # type: ignore + table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) # type: ignore if isinstance(self.data, list): - # big ol column table.setRowCount(len(data)) table.setColumnCount(1) for i, item in enumerate(self.data): @@ -50,10 +64,10 @@ class QuestionBoxDetails(QDialog): try: # | TIT2 | title goes here | # | TDRC | 2025-05-05 | - table.setRowCount(len(data.keys())) + table.setRowCount(len(data.keys())) # type: ignore table.setColumnCount(2) - table.setHorizontalHeaderLabels(['Tag', 'Value']) - for i, (k, v) in enumerate(data.items()): + table.setHorizontalHeaderLabels(['Key', 'Value']) + for i, (k, v) in enumerate(data.items()): # type: ignore table.setItem(i, 0, QTableWidgetItem(str(k))) table.setItem(i, 1, QTableWidgetItem(str(v))) layout.addWidget(table) @@ -62,21 +76,39 @@ class QuestionBoxDetails(QDialog): self.input_field = QPlainTextEdit(pformat(data + "\n\n" + str(e))) layout.addWidget(self.input_field) error(f'Tried to load self.data as dict but could not. {e}') - # ok - ok_button = QPushButton("Confirm") - ok_button.clicked.connect(self.ok) - h_layout.addWidget(ok_button) - # cancel - cancel_button = QPushButton("no") - cancel_button.clicked.connect(self.cancel) - h_layout.addWidget(cancel_button) + + # dynamically load the button choices + def create_button(choice): + def func(): + self.reply = choice[0] + self.close() + button = QPushButton(choice[1]) + button.clicked.connect(func) + return button + + for idx, choice in enumerate(choices): + button = create_button(choice) + h_layout.addWidget(button) + if idx == 0: + button.setFocus() + + + # # ok + # ok_button = QPushButton("Confirm") + # ok_button.clicked.connect(self.ok) + # h_layout.addWidget(ok_button) + # # cancel + # cancel_button = QPushButton("no") + # cancel_button.clicked.connect(self.cancel) + # h_layout.addWidget(cancel_button) layout.addLayout(h_layout) self.setLayout(layout) - ok_button.setFocus() - def execute(self): + """ + Returns when `self` is closed + """ self.exec_() return self.reply diff --git a/utils/__init__.py b/utils/__init__.py index b3bf1e5..7cb5d42 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,5 +1,5 @@ from .fft_analyser import FFTAnalyser -from .convert_id3_timestamp_to_datetime import convert_id3_timestamp_to_datetime +from .handle_date_tag import handle_date_tag from .initialize_db import initialize_db from .safe_get import safe_get from .get_album_art import get_album_art @@ -7,6 +7,7 @@ from .get_tags import get_tags, id3_remap from .get_reorganize_vars import get_reorganize_vars, parse_artist_album from .set_tag import set_tag from .delete_song_id_from_database import delete_song_id_from_database +from .batch_delete_filepaths_from_filesystem import batch_delete_filepaths_from_filesystem from .batch_delete_filepaths_from_database import batch_delete_filepaths_from_database from .batch_delete_filepaths_from_playlist import batch_delete_filepaths_from_playlist from .delete_and_create_library_database import delete_and_create_library_database diff --git a/utils/add_files_to_database.py b/utils/add_files_to_database.py index 323d8c5..123946f 100644 --- a/utils/add_files_to_database.py +++ b/utils/add_files_to_database.py @@ -85,7 +85,7 @@ def add_files_to_database(files: list[str], playlist_id: int | None = None, prog insert_data = [] # Reset the insert_data list # Insert any remaining data after reading every file if insert_data: - debug(f'inserting the rest of the songs | insert_data={insert_data}') + # debug(f'inserting the rest of the songs | insert_data={insert_data}') with DBA.DBAccess() as db: db.executemany( "INSERT OR IGNORE INTO song (filepath, title, album, artist, track_number, genre, codec, album_date, bitrate, length_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", diff --git a/utils/batch_delete_filepaths_from_filesystem.py b/utils/batch_delete_filepaths_from_filesystem.py new file mode 100644 index 0000000..23ef697 --- /dev/null +++ b/utils/batch_delete_filepaths_from_filesystem.py @@ -0,0 +1,34 @@ +import logging +import os +from components.ErrorDialog import ErrorDialog + + +def batch_delete_filepaths_from_filesystem( + files: list[str], progress_callback=None +) -> bool: + """ + Handles deleting song files from filesystem + + Args: + - files: a list of absolute filepaths to songs + - progress_callback: emit this signal for user feedback + + Returns True on success + False on failure/error + """ + # Get song IDs from filepaths + try: + for file in files: + os.remove(file) + if progress_callback: + progress_callback.emit(f'Removing {file}') + except Exception as e: + logging.error( + f"An error occurred during batch processing: {e}" + ) + dialog = ErrorDialog( + f"An error occurred during batch processing: {e}" + ) + dialog.exec_() + return False + return True diff --git a/utils/convert_id3_timestamp_to_datetime.py b/utils/convert_id3_timestamp_to_datetime.py deleted file mode 100644 index fa24c9d..0000000 --- a/utils/convert_id3_timestamp_to_datetime.py +++ /dev/null @@ -1,18 +0,0 @@ -import datetime -from mutagen.id3._specs import ID3TimeStamp - - -def convert_id3_timestamp_to_datetime(timestamp): - """Turns a mutagen ID3TimeStamp into a format that SQLite can use for Date field""" - if timestamp is None: - return - if not isinstance(timestamp, ID3TimeStamp): - return - if len(timestamp.text) == 4: # If only year is provided - datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y") - else: - try: - datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y-%m-%d") - except ValueError: - datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y%m%d") - return datetime_obj.strftime("%Y-%m-%d") diff --git a/utils/delete_album_art.py b/utils/delete_album_art.py index b8fe05b..c81d95d 100644 --- a/utils/delete_album_art.py +++ b/utils/delete_album_art.py @@ -16,7 +16,7 @@ def delete_album_art(file: str) -> bool: try: debug("Deleting album art") audio = ID3(file) - debug(audio) + # debug(audio) if "APIC:" in audio: del audio["APIC:"] debug("Deleting album art for real this time") diff --git a/utils/export_playlist_by_id.py b/utils/export_playlist_by_id.py index fba2f14..5d3f2a2 100644 --- a/utils/export_playlist_by_id.py +++ b/utils/export_playlist_by_id.py @@ -62,12 +62,10 @@ def export_playlist_by_id(playlist_db_id: int, progress_callback=None) -> bool: artist, album = parse_artist_album(song) write_path = Path(path_prefix) / artist / album / song.name write_paths.append(str(write_path) + "\n") - # write_to_playlist_file(write_paths, auto_export_path) - - worker = Worker(write_to_playlist_file, write_paths, auto_export_path) - # worker.signals.signal_finished.connect(None) - # worker.signals.signal_progress.connect() - threadpool.start(worker) + worker = Worker(write_to_playlist_file, write_paths, auto_export_path) + # worker.signals.signal_finished.connect(None) + # worker.signals.signal_progress.connect() + threadpool.start(worker) return True diff --git a/utils/get_reorganize_vars.py b/utils/get_reorganize_vars.py index a3ef9d3..b33ac87 100644 --- a/utils/get_reorganize_vars.py +++ b/utils/get_reorganize_vars.py @@ -57,6 +57,9 @@ def decode_text_frame(data): def parse_artist_album(filepath) -> tuple[str, str]: + """ + ai slop plus riley + """ with open(filepath, "rb") as f: header = f.read(10) diff --git a/utils/get_tags.py b/utils/get_tags.py index 3ecf2e6..b85f240 100644 --- a/utils/get_tags.py +++ b/utils/get_tags.py @@ -6,7 +6,7 @@ from mutagen.flac import FLAC from mutagen.id3._frames import TIT2 from mutagen.id3._util import ID3NoHeaderError -from utils import convert_id3_timestamp_to_datetime +from utils import handle_date_tag def get_mp3_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: @@ -52,7 +52,7 @@ def id3_remap(audio: MP3 | ID3 | FLAC) -> dict[str, str | int | None]: "album": audio.get("TALB"), "track_number": audio.get("TRCK"), "genre": audio.get("TCON"), - "date": convert_id3_timestamp_to_datetime(audio.get("TDRC")), + "date": handle_date_tag(audio.get("TDRC")), "bitrate": audio.get("TBIT"), "lyrics": lyrics, "length": int(round(audio.info.length, 0)), @@ -88,7 +88,10 @@ def get_tags(filename: str) -> tuple[MP3 | ID3 | FLAC, str]: """ # FIXME: this is where i implement other filetypes if filename.lower().endswith(".mp3"): - tags, details = get_mp3_tags(filename) + try: + tags, details = get_mp3_tags(filename) + except Exception as e: + tags, details = ID3(), str(e) else: tags, details = ID3(), "non mp3 file" return tags, details diff --git a/utils/handle_date_tag.py b/utils/handle_date_tag.py new file mode 100644 index 0000000..854e4ad --- /dev/null +++ b/utils/handle_date_tag.py @@ -0,0 +1,25 @@ +import datetime +from mutagen.id3._specs import ID3TimeStamp +from logging import debug, error +import dateutil.parser + +def handle_date_tag(tag) -> str | None: + """ + Handles TDRC ID3 tag and what not + # {handle_date_tag.py:9} DEBUG - tag = 2025-11-05 + # {handle_date_tag.py:10} DEBUG - tag type = + # {handle_date_tag.py:17} DEBUG - tag.text[0] = 2025-11-05 + # {handle_date_tag.py:18} DEBUG - tag.text[0] type = + """ + if tag is None: + return None + if isinstance(tag, ID3TimeStamp): + return None + + try: + datetime = dateutil.parser.parse(str(tag)) + formatted_date = datetime.strftime("%Y-%m-%d") + return formatted_date + except ValueError as e: + error(f'date tag is incomprehensible. | tag = {tag} | error: {e}') + return None diff --git a/utils/set_tag.py b/utils/set_tag.py index bf64392..d8d5b1a 100644 --- a/utils/set_tag.py +++ b/utils/set_tag.py @@ -19,7 +19,7 @@ def set_tag(filepath: str, db_column: str, value: str): True / False """ headers = HeaderTags2() - debug(f"filepath: {filepath} | db_column: {db_column} | value: {value}") + # debug(f"filepath: {filepath} | db_column: {db_column} | value: {value}") try: try: # Load existing tags