lots of fixes for normal activity

This commit is contained in:
billy@pom 2026-05-03 16:08:47 -04:00
parent 84221f92ae
commit df9399403a
14 changed files with 155 additions and 78 deletions

View File

@ -16,7 +16,7 @@ class EditPlaylistOptionsWindow(QDialog):
# super(EditPlaylistOptionsWindow, self).__init__() # super(EditPlaylistOptionsWindow, self).__init__()
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Playlist options") self.setWindowTitle("Playlist options")
# self.setMinimumSize(800, 200) self.setMinimumSize(400, 300)
self.playlist_id = playlist_id self.playlist_id = playlist_id
# self.playlist_path_prefix: str = self.config.get( # self.playlist_path_prefix: str = self.config.get(
# "settings", "playlist_path_prefix" # "settings", "playlist_path_prefix"
@ -61,7 +61,7 @@ class EditPlaylistOptionsWindow(QDialog):
label.setFont(QFont("Sans", weight=QFont.Bold)) label.setFont(QFont("Sans", weight=QFont.Bold))
layout.addWidget(label) layout.addWidget(label)
label = QLabel('i.e.: ../music/playlists/my-playlist.m3u') 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) layout.addWidget(label)
# Relative export path line edit widget # Relative export path line edit widget
@ -73,7 +73,7 @@ class EditPlaylistOptionsWindow(QDialog):
label.setFont(QFont("Sans", weight=QFont.Bold)) label.setFont(QFont("Sans", weight=QFont.Bold))
layout.addWidget(label) layout.addWidget(label)
label = QLabel("i.e.: /prefix/song.mp3") 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) layout.addWidget(label)
# Playlist file save path line edit widget # Playlist file save path line edit widget

View File

@ -46,7 +46,7 @@ class LyricsWindow(QDialog):
value=self.input_field.toPlainText(), value=self.input_field.toPlainText(),
) )
if success: if success:
debug("lyrical success! yay") debug("Lyrics saved successfully")
else: else:
error_dialog = ErrorDialog("Could not save lyrics :( sad") error_dialog = ErrorDialog("Could not save lyrics :( sad")
error_dialog.exec() error_dialog.exec()

View File

@ -38,6 +38,7 @@ from components.QuestionBoxDetails import QuestionBoxDetails
from components.HeaderTags import HeaderTags2 from components.HeaderTags import HeaderTags2
from utils import ( from utils import (
batch_delete_filepaths_from_filesystem,
batch_delete_filepaths_from_database, batch_delete_filepaths_from_database,
batch_delete_filepaths_from_playlist, batch_delete_filepaths_from_playlist,
add_files_to_database, add_files_to_database,
@ -315,7 +316,7 @@ class MusicTable(QTableView):
if directories: if directories:
debug('Spawning worker thread for directories') debug('Spawning worker thread for directories')
worker = Worker(self.get_audio_files_recursively, 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( _ = worker.signals.signal_result.connect(
self.on_get_audio_files_recursively_finished self.on_get_audio_files_recursively_finished
) )
@ -645,7 +646,7 @@ class MusicTable(QTableView):
- Drag & Drop song(s) on tableView - Drag & Drop song(s) on tableView
- File > Open > List of song(s) - 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 = Worker(add_files_to_database, files, None)
_ = worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore _ = worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore
_ = worker.signals.signal_result.connect(self.on_add_files_to_database_finished) _ = 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)""" """Asks to delete the currently selected songs from the db and music table (not the filesystem)"""
# NOTE: provide extra questionbox option? # NOTE: provide extra questionbox option?
# | Delete from playlist & lib | Delete from playlist only | Cancel | # | 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() selected_filepaths = self.get_selected_songs_filepaths()
if self.selected_playlist_id: if self.selected_playlist_id:
question_dialog = QuestionBoxDetails( question_dialog = QuestionBoxDetails(
title="Delete songs", title="Delete songs",
description="Remove these songs from the playlist?", description="Remove these songs from the playlist?",
choices=[(1, 'Confirm'), (0, 'Cancel')],
data=selected_filepaths, data=selected_filepaths,
) )
reply = question_dialog.execute() reply = question_dialog.execute()
if reply: if reply == 1:
worker = Worker( worker = Worker(
batch_delete_filepaths_from_playlist, batch_delete_filepaths_from_playlist,
selected_filepaths, selected_filepaths,
@ -701,11 +703,12 @@ class MusicTable(QTableView):
else: else:
question_dialog = QuestionBoxDetails( question_dialog = QuestionBoxDetails(
title="Delete songs", 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, data=selected_filepaths,
) )
reply = question_dialog.execute() reply = question_dialog.execute()
if reply: if reply == 1 or reply == 2:
worker = Worker( worker = Worker(
batch_delete_filepaths_from_database, selected_filepaths batch_delete_filepaths_from_database, selected_filepaths
) )
@ -714,21 +717,18 @@ class MusicTable(QTableView):
if self.qapp: if self.qapp:
threadpool = self.qapp.threadpool # type: ignore threadpool = self.qapp.threadpool # type: ignore
threadpool.start(worker) 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): def delete_selected_row_indices(self):
""" """
@ -845,7 +845,7 @@ class MusicTable(QTableView):
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
worker = Worker(self.reorganize_files, filepaths) 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) worker.signals.signal_finished.connect(self.load_music_table)
self.qapp.threadpool.start(worker) # type: ignore self.qapp.threadpool.start(worker) # type: ignore
@ -1128,7 +1128,6 @@ class MusicTable(QTableView):
audio_files.append(os.path.join(root, file)) audio_files.append(os.path.join(root, file))
if progress_callback: if progress_callback:
progress_callback.emit(f"Scanning {file}") progress_callback.emit(f"Scanning {file}")
debug(f'get_audio_files_recursively() return: {audio_files}')
return audio_files return audio_files
def get_selected_rows(self) -> list[int]: def get_selected_rows(self) -> list[int]:

View File

@ -15,15 +15,30 @@ from pprint import pformat
class QuestionBoxDetails(QDialog): 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__() super(QuestionBoxDetails, self).__init__()
self.title: str = title self.title: str = title
self.description: str = description self.description: str = description
self.data: str = data self.data: str | list | dict = data
self.reply: bool = False self.reply: int = -1
self.setWindowTitle(title) self.setWindowTitle(title)
self.setMinimumSize(400, 400) self.setMinimumSize(600, 400)
self.setMaximumSize(600,1000) self.setMaximumSize(600, 400)
layout = QVBoxLayout() layout = QVBoxLayout()
h_layout = QHBoxLayout() h_layout = QHBoxLayout()
@ -37,10 +52,9 @@ class QuestionBoxDetails(QDialog):
else: else:
table: QTableWidget = QTableWidget() table: QTableWidget = QTableWidget()
table.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) table.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
table.horizontalHeader().setStretchLastSection(True) table.horizontalHeader().setStretchLastSection(True) # type: ignore
table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) # type: ignore
if isinstance(self.data, list): if isinstance(self.data, list):
# big ol column
table.setRowCount(len(data)) table.setRowCount(len(data))
table.setColumnCount(1) table.setColumnCount(1)
for i, item in enumerate(self.data): for i, item in enumerate(self.data):
@ -50,10 +64,10 @@ class QuestionBoxDetails(QDialog):
try: try:
# | TIT2 | title goes here | # | TIT2 | title goes here |
# | TDRC | 2025-05-05 | # | TDRC | 2025-05-05 |
table.setRowCount(len(data.keys())) table.setRowCount(len(data.keys())) # type: ignore
table.setColumnCount(2) table.setColumnCount(2)
table.setHorizontalHeaderLabels(['Tag', 'Value']) table.setHorizontalHeaderLabels(['Key', 'Value'])
for i, (k, v) in enumerate(data.items()): for i, (k, v) in enumerate(data.items()): # type: ignore
table.setItem(i, 0, QTableWidgetItem(str(k))) table.setItem(i, 0, QTableWidgetItem(str(k)))
table.setItem(i, 1, QTableWidgetItem(str(v))) table.setItem(i, 1, QTableWidgetItem(str(v)))
layout.addWidget(table) layout.addWidget(table)
@ -62,21 +76,39 @@ class QuestionBoxDetails(QDialog):
self.input_field = QPlainTextEdit(pformat(data + "\n\n" + str(e))) self.input_field = QPlainTextEdit(pformat(data + "\n\n" + str(e)))
layout.addWidget(self.input_field) layout.addWidget(self.input_field)
error(f'Tried to load self.data as dict but could not. {e}') error(f'Tried to load self.data as dict but could not. {e}')
# ok
ok_button = QPushButton("Confirm") # dynamically load the button choices
ok_button.clicked.connect(self.ok) def create_button(choice):
h_layout.addWidget(ok_button) def func():
# cancel self.reply = choice[0]
cancel_button = QPushButton("no") self.close()
cancel_button.clicked.connect(self.cancel) button = QPushButton(choice[1])
h_layout.addWidget(cancel_button) 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) layout.addLayout(h_layout)
self.setLayout(layout) self.setLayout(layout)
ok_button.setFocus()
def execute(self): def execute(self):
"""
Returns when `self` is closed
"""
self.exec_() self.exec_()
return self.reply return self.reply

View File

@ -1,5 +1,5 @@
from .fft_analyser import FFTAnalyser 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 .initialize_db import initialize_db
from .safe_get import safe_get from .safe_get import safe_get
from .get_album_art import get_album_art 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 .get_reorganize_vars import get_reorganize_vars, parse_artist_album
from .set_tag import set_tag from .set_tag import set_tag
from .delete_song_id_from_database import delete_song_id_from_database 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_database import batch_delete_filepaths_from_database
from .batch_delete_filepaths_from_playlist import batch_delete_filepaths_from_playlist from .batch_delete_filepaths_from_playlist import batch_delete_filepaths_from_playlist
from .delete_and_create_library_database import delete_and_create_library_database from .delete_and_create_library_database import delete_and_create_library_database

View File

@ -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_data = [] # Reset the insert_data list
# Insert any remaining data after reading every file # Insert any remaining data after reading every file
if insert_data: 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: with DBA.DBAccess() as db:
db.executemany( db.executemany(
"INSERT OR IGNORE INTO song (filepath, title, album, artist, track_number, genre, codec, album_date, bitrate, length_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", "INSERT OR IGNORE INTO song (filepath, title, album, artist, track_number, genre, codec, album_date, bitrate, length_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",

View File

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

View File

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

View File

@ -16,7 +16,7 @@ def delete_album_art(file: str) -> bool:
try: try:
debug("Deleting album art") debug("Deleting album art")
audio = ID3(file) audio = ID3(file)
debug(audio) # debug(audio)
if "APIC:" in audio: if "APIC:" in audio:
del audio["APIC:"] del audio["APIC:"]
debug("Deleting album art for real this time") debug("Deleting album art for real this time")

View File

@ -62,8 +62,6 @@ def export_playlist_by_id(playlist_db_id: int, progress_callback=None) -> bool:
artist, album = parse_artist_album(song) artist, album = parse_artist_album(song)
write_path = Path(path_prefix) / artist / album / song.name write_path = Path(path_prefix) / artist / album / song.name
write_paths.append(str(write_path) + "\n") 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 = Worker(write_to_playlist_file, write_paths, auto_export_path)
# worker.signals.signal_finished.connect(None) # worker.signals.signal_finished.connect(None)
# worker.signals.signal_progress.connect() # worker.signals.signal_progress.connect()

View File

@ -57,6 +57,9 @@ def decode_text_frame(data):
def parse_artist_album(filepath) -> tuple[str, str]: def parse_artist_album(filepath) -> tuple[str, str]:
"""
ai slop plus riley
"""
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
header = f.read(10) header = f.read(10)

View File

@ -6,7 +6,7 @@ from mutagen.flac import FLAC
from mutagen.id3._frames import TIT2 from mutagen.id3._frames import TIT2
from mutagen.id3._util import ID3NoHeaderError 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]: 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"), "album": audio.get("TALB"),
"track_number": audio.get("TRCK"), "track_number": audio.get("TRCK"),
"genre": audio.get("TCON"), "genre": audio.get("TCON"),
"date": convert_id3_timestamp_to_datetime(audio.get("TDRC")), "date": handle_date_tag(audio.get("TDRC")),
"bitrate": audio.get("TBIT"), "bitrate": audio.get("TBIT"),
"lyrics": lyrics, "lyrics": lyrics,
"length": int(round(audio.info.length, 0)), "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 # FIXME: this is where i implement other filetypes
if filename.lower().endswith(".mp3"): if filename.lower().endswith(".mp3"):
try:
tags, details = get_mp3_tags(filename) tags, details = get_mp3_tags(filename)
except Exception as e:
tags, details = ID3(), str(e)
else: else:
tags, details = ID3(), "non mp3 file" tags, details = ID3(), "non mp3 file"
return tags, details return tags, details

25
utils/handle_date_tag.py Normal file
View File

@ -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 = <class 'mutagen.id3.TDRC'>
# {handle_date_tag.py:17} DEBUG - tag.text[0] = 2025-11-05
# {handle_date_tag.py:18} DEBUG - tag.text[0] type = <class 'mutagen.id3._specs.ID3TimeStamp'>
"""
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

View File

@ -19,7 +19,7 @@ def set_tag(filepath: str, db_column: str, value: str):
True / False True / False
""" """
headers = HeaderTags2() headers = HeaderTags2()
debug(f"filepath: {filepath} | db_column: {db_column} | value: {value}") # debug(f"filepath: {filepath} | db_column: {db_column} | value: {value}")
try: try:
try: # Load existing tags try: # Load existing tags