newly added playlist builtin

This commit is contained in:
billy@pom 2026-03-15 22:39:43 -04:00
parent d76bd67311
commit caecf63f6a
6 changed files with 316 additions and 168 deletions

View File

@ -46,16 +46,18 @@ from utils import (
id3_remap, id3_remap,
get_tags, get_tags,
set_tag, set_tag,
Worker Worker,
) )
from subprocess import Popen from subprocess import Popen
from logging import debug, error from logging import debug, error
import os
import shutil
import typing
from pathlib import Path from pathlib import Path
from appdirs import user_config_dir from appdirs import user_config_dir
from configparser import ConfigParser from configparser import ConfigParser
import os
import shutil
import typing
import datetime
class MusicTable(QTableView): class MusicTable(QTableView):
playlistStatsSignal: pyqtSignal = pyqtSignal(str) playlistStatsSignal: pyqtSignal = pyqtSignal(str)
@ -83,7 +85,7 @@ class MusicTable(QTableView):
/ "config.ini" / "config.ini"
) )
_ = self.config.read(self.cfg_file) _ = self.config.read(self.cfg_file)
self.sort_config: str = self.config.get('table', 'sort_order') self.sort_config: str = self.config.get("table", "sort_order")
self.column_sort_order: list[tuple[str, Qt.SortOrder | None]] = [] self.column_sort_order: list[tuple[str, Qt.SortOrder | None]] = []
font: QFont = QFont() font: QFont = QFont()
@ -135,8 +137,12 @@ class MusicTable(QTableView):
self.horizontal_header.setSectionResizeMode(QHeaderView.Interactive) self.horizontal_header.setSectionResizeMode(QHeaderView.Interactive)
# self.horizontal_header.sortIndicatorChanged.connect(self.on_user_sort_change) # self.horizontal_header.sortIndicatorChanged.connect(self.on_user_sort_change)
self.horizontal_header.sectionClicked.connect(self.on_header_clicked) self.horizontal_header.sectionClicked.connect(self.on_header_clicked)
self.horizontal_header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.horizontal_header.setContextMenuPolicy(
self.horizontal_header.customContextMenuRequested.connect(self.show_header_context_menu) Qt.ContextMenuPolicy.CustomContextMenu
)
self.horizontal_header.customContextMenuRequested.connect(
self.show_header_context_menu
)
# dumb vertical estupido # dumb vertical estupido
self.vertical_header: QHeaderView = self.verticalHeader() self.vertical_header: QHeaderView = self.verticalHeader()
assert self.vertical_header is not None assert self.vertical_header is not None
@ -173,7 +179,6 @@ class MusicTable(QTableView):
""" """
self.focusLeaveSignal.emit() self.focusLeaveSignal.emit()
def resizeEvent(self, e: typing.Optional[QResizeEvent]) -> None: def resizeEvent(self, e: typing.Optional[QResizeEvent]) -> None:
""" """
Do something when the QTableView is resized Do something when the QTableView is resized
@ -194,9 +199,11 @@ class MusicTable(QTableView):
super().showEvent(a0) super().showEvent(a0)
widths = [] widths = []
for _ in self.saved_column_ratios: for _ in self.saved_column_ratios:
widths.append('0.001') widths.append("0.001")
self.load_header_widths(widths) self.load_header_widths(widths)
QTimer.singleShot(0, lambda: self.load_header_widths(ratios=self.saved_column_ratios)) QTimer.singleShot(
0, lambda: self.load_header_widths(ratios=self.saved_column_ratios)
)
def paintEvent(self, e): def paintEvent(self, e):
"""Override paint event to highlight the current cell""" """Override paint event to highlight the current cell"""
@ -223,7 +230,6 @@ class MusicTable(QTableView):
menu = QMenu(self) menu = QMenu(self)
menu.setFont(font) menu.setFont(font)
# OLD CODE but i keep cus i sais so # OLD CODE but i keep cus i sais so
# add_to_playlist_action = QAction("Add to playlist", self) # add_to_playlist_action = QAction("Add to playlist", self)
# _ = add_to_playlist_action.triggered.connect(self.open_add_to_playlist_window) # _ = add_to_playlist_action.triggered.connect(self.open_add_to_playlist_window)
@ -236,7 +242,8 @@ class MusicTable(QTableView):
for playlist_id, playlist_name in playlists: for playlist_id, playlist_name in playlists:
action = QAction(playlist_name, add_to_playlist_menu) action = QAction(playlist_name, add_to_playlist_menu)
action.triggered.connect( action.triggered.connect(
lambda checked=False, pid=playlist_id: self.add_selected_songs_to_playlist(pid) lambda checked=False,
pid=playlist_id: self.add_selected_songs_to_playlist(pid)
) )
add_to_playlist_menu.addAction(action) add_to_playlist_menu.addAction(action)
menu.addMenu(add_to_playlist_menu) menu.addMenu(add_to_playlist_menu)
@ -254,8 +261,7 @@ class MusicTable(QTableView):
_ = jump_to_current_song_action.triggered.connect(self.jump_to_current_song) _ = jump_to_current_song_action.triggered.connect(self.jump_to_current_song)
menu.addAction(jump_to_current_song_action) menu.addAction(jump_to_current_song_action)
# open in file explorer # open in file explorer
open_containing_folder_action = QAction( open_containing_folder_action = QAction("Open in system file manager", self)
"Open in system file manager", self)
_ = open_containing_folder_action.triggered.connect(self.open_directory) _ = open_containing_folder_action.triggered.connect(self.open_directory)
menu.addAction(open_containing_folder_action) menu.addAction(open_containing_folder_action)
# view id3 tags (debug) # view id3 tags (debug)
@ -309,10 +315,12 @@ class MusicTable(QTableView):
if directories: if 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.handle_progress)
_ = worker.signals.signal_result.connect(self.on_get_audio_files_recursively_finished) _ = worker.signals.signal_result.connect(
self.on_get_audio_files_recursively_finished
)
_ = worker.signals.signal_finished.connect(self.load_music_table) _ = worker.signals.signal_finished.connect(self.load_music_table)
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 files: if files:
self.add_files_to_library(files) self.add_files_to_library(files)
@ -372,7 +380,6 @@ class MusicTable(QTableView):
else: # Default behavior else: # Default behavior
super().keyPressEvent(e) super().keyPressEvent(e)
# ____________________ # ____________________
# | | # | |
# | | # | |
@ -387,7 +394,6 @@ class MusicTable(QTableView):
self.find_current_and_selected_bits() self.find_current_and_selected_bits()
self.jump_to_selected_song() self.jump_to_selected_song()
def on_cell_clicked(self, index): def on_cell_clicked(self, index):
""" """
When a cell is clicked, do some stuff :) When a cell is clicked, do some stuff :)
@ -412,18 +418,15 @@ class MusicTable(QTableView):
edited_column_name: str = self.headers.db_list[topLeft.column()] edited_column_name: str = self.headers.db_list[topLeft.column()]
# debug(f"on_cell_data_changed | edited column name: {edited_column_name}") # debug(f"on_cell_data_changed | edited column name: {edited_column_name}")
response = set_tag( response = set_tag(
filepath=filepath, filepath=filepath, db_column=edited_column_name, value=user_input_data
db_column=edited_column_name,
value=user_input_data
) )
if response: if response:
# Update the library with new metadata # Update the library with new metadata
_ = update_song_in_database(song_id, edited_column_name, user_input_data) _ = update_song_in_database(song_id, edited_column_name, user_input_data)
else: else:
error('ERROR: response failed') error("ERROR: response failed")
return return
def on_get_audio_files_recursively_finished(self, result: list[str]): def on_get_audio_files_recursively_finished(self, result: list[str]):
"""file search completion handler""" """file search completion handler"""
if result: if result:
@ -448,8 +451,7 @@ class MusicTable(QTableView):
except IndexError: except IndexError:
pass pass
except Exception as e: except Exception as e:
debug( debug(f"on_add_files_to_database_finished() | Something went wrong: {e}")
f"on_add_files_to_database_finished() | Something went wrong: {e}")
# ____________________ # ____________________
# | | # | |
@ -458,7 +460,6 @@ class MusicTable(QTableView):
# | | # | |
# |____________________| # |____________________|
def handle_progress(self, data: object): def handle_progress(self, data: object):
"""Emits data to main""" """Emits data to main"""
self.handleProgressSignal.emit(data) self.handleProgressSignal.emit(data)
@ -468,8 +469,8 @@ class MusicTable(QTableView):
Show context menu on right-click in the horizontal header. Show context menu on right-click in the horizontal header.
""" """
# NOTE: # NOTE:
# Jus a side note, creating a nemu on the heap every time the user right clicks # Jus a side note, creating a nemu on the heap every time the user right clicks
# is going to eat up a lot of memory in the long term. It's much more efficient # is going to eat up a lot of memory in the long term. It's much more efficient
# to create it once and call popup on it when needed # to create it once and call popup on it when needed
# NOTE: # NOTE:
@ -484,17 +485,13 @@ class MusicTable(QTableView):
sort_asc_action = QAction("Sort ASC", self) sort_asc_action = QAction("Sort ASC", self)
sort_asc_action.triggered.connect( sort_asc_action.triggered.connect(
lambda checked=False: self.mark_column_sort( lambda checked=False: self.mark_column_sort(
logical_index, logical_index, column_name, Qt.SortOrder.AscendingOrder
column_name,
Qt.SortOrder.AscendingOrder
) )
) )
sort_desc_action = QAction("Sort DESC", self) sort_desc_action = QAction("Sort DESC", self)
sort_desc_action.triggered.connect( sort_desc_action.triggered.connect(
lambda checked=False: self.mark_column_sort( lambda checked=False: self.mark_column_sort(
logical_index, logical_index, column_name, Qt.SortOrder.DescendingOrder
column_name,
Qt.SortOrder.DescendingOrder
) )
) )
menu.addAction(clear_action) menu.addAction(clear_action)
@ -506,7 +503,7 @@ class MusicTable(QTableView):
def mark_column_sort(self, logical_index, column_name, sort_order): def mark_column_sort(self, logical_index, column_name, sort_order):
""" """
Marks a column as sorted in a particular direction Marks a column as sorted in a particular direction
- logical_index: called from `show_header_context_menu()` - logical_index: called from `show_header_context_menu()`
- column_name: database column name - column_name: database column name
- sort_order: 0 = asc, 1 = desc - sort_order: 0 = asc, 1 = desc
@ -515,7 +512,9 @@ class MusicTable(QTableView):
# write field name and the sort direction into config # write field name and the sort direction into config
# then run sort by logical fields or something to sort the config as is # then run sort by logical fields or something to sort the config as is
debug(f'mark_column_sort(logical_index={logical_index}, column_name={column_name}, sort_order={sort_order})') debug(
f"mark_column_sort(logical_index={logical_index}, column_name={column_name}, sort_order={sort_order})"
)
raw = self.sort_config raw = self.sort_config
# Clear the sort order list # Clear the sort order list
self.column_sort_order = [] self.column_sort_order = []
@ -538,12 +537,13 @@ class MusicTable(QTableView):
else: else:
direction = None direction = None
self.column_sort_order.append((field, direction)) self.column_sort_order.append((field, direction))
debug(f'- sort list updated (field, direction): {self.column_sort_order}') debug(f"- sort list updated (field, direction): {self.column_sort_order}")
self.sort_config = ",".join(f'{field}:{order}' for field, order in self.column_sort_order) self.sort_config = ",".join(
f"{field}:{order}" for field, order in self.column_sort_order
)
# Re-apply the updated sort order # Re-apply the updated sort order
self.sort_by_logical_fields() self.sort_by_logical_fields()
def clear_all_sorts(self): def clear_all_sorts(self):
""" """
Clears all stored sort orders and refreshes the table view. Clears all stored sort orders and refreshes the table view.
@ -551,7 +551,6 @@ class MusicTable(QTableView):
# Clear sort visually # Clear sort visually
self.horizontal_header.setSortIndicator(-1, Qt.SortOrder.AscendingOrder) self.horizontal_header.setSortIndicator(-1, Qt.SortOrder.AscendingOrder)
def find_qmodel_index_by_value(self, model, column: int, value) -> QModelIndex: def find_qmodel_index_by_value(self, model, column: int, value) -> QModelIndex:
""" """
Returns the QModelIndex of a specified model, based on column number & field value Returns the QModelIndex of a specified model, based on column number & field value
@ -563,7 +562,7 @@ class MusicTable(QTableView):
return QModelIndex() return QModelIndex()
if column > model.columnCount(): if column > model.columnCount():
return model.index(0,0) return model.index(0, 0)
if value: if value:
for row in range(model.rowCount()): for row in range(model.rowCount()):
@ -571,8 +570,7 @@ class MusicTable(QTableView):
if index.data() == value: if index.data() == value:
return index return index
return model.index(0,0) return model.index(0, 0)
def get_current_header_width_ratios(self) -> list[str]: def get_current_header_width_ratios(self) -> list[str]:
""" """
@ -581,7 +579,7 @@ class MusicTable(QTableView):
total_table_width = self.size().width() total_table_width = self.size().width()
column_ratios = [] column_ratios = []
for i in range(self.model2.columnCount()): for i in range(self.model2.columnCount()):
# for i in range(self.model2.columnCount() - 1): # for i in range(self.model2.columnCount() - 1):
# debug(f'column count: {self.model2.columnCount()}') # debug(f'column count: {self.model2.columnCount()}')
column_width = self.columnWidth(i) column_width = self.columnWidth(i)
ratio = column_width / total_table_width ratio = column_width / total_table_width
@ -589,12 +587,11 @@ class MusicTable(QTableView):
# debug(f'get_current_header_width_ratios = {column_ratios}') # debug(f'get_current_header_width_ratios = {column_ratios}')
return column_ratios return column_ratios
def save_header_ratios(self): def save_header_ratios(self):
""" """
Saves the current header widths to memory and file, as ratios Saves the current header widths to memory and file, as ratios
""" """
# WARNING: DOES NOT WORK and is not used. # WARNING: DOES NOT WORK and is not used.
# the same functionality is implemented in main.py closeEvent() # the same functionality is implemented in main.py closeEvent()
self.saved_column_ratios = self.get_current_header_width_ratios() self.saved_column_ratios = self.get_current_header_width_ratios()
column_ratios_as_string = ",".join(self.saved_column_ratios) column_ratios_as_string = ",".join(self.saved_column_ratios)
@ -607,7 +604,6 @@ class MusicTable(QTableView):
debug(f"wtf man {e}") debug(f"wtf man {e}")
# debug(f"Saved column ratios: {self.saved_column_ratios}") # debug(f"Saved column ratios: {self.saved_column_ratios}")
def load_header_widths(self, ratios: list[str] | None = None): def load_header_widths(self, ratios: list[str] | None = None):
""" """
Loads the header widths, based on saved ratios Loads the header widths, based on saved ratios
@ -623,15 +619,13 @@ class MusicTable(QTableView):
for ratio in column_ratios: for ratio in column_ratios:
column_widths.append(float(ratio) * total_table_width) column_widths.append(float(ratio) * total_table_width)
if isinstance(column_widths, list): if isinstance(column_widths, list):
for i in range(self.model2.columnCount()-1): for i in range(self.model2.columnCount() - 1):
# load all except the last ratio - bcus it gets autostretched # load all except the last ratio - bcus it gets autostretched
self.setColumnWidth(i, int(column_widths[i])) self.setColumnWidth(i, int(column_widths[i]))
def set_qmodel_index(self, index: QModelIndex): def set_qmodel_index(self, index: QModelIndex):
self.current_song_qmodel_index = index self.current_song_qmodel_index = index
def play_selected_audio_file(self): def play_selected_audio_file(self):
""" """
Sets the current song filepath Sets the current song filepath
@ -641,7 +635,6 @@ class MusicTable(QTableView):
self.set_current_song_filepath() self.set_current_song_filepath()
self.playSignal.emit(self.current_song_filepath) self.playSignal.emit(self.current_song_filepath)
def add_files_to_library(self, files: list[str]) -> None: def add_files_to_library(self, files: list[str]) -> None:
""" """
Spawns a worker thread - adds a list of filepaths to the library Spawns a worker thread - adds a list of filepaths to the library
@ -651,20 +644,18 @@ class MusicTable(QTableView):
""" """
# debug('add_files_to_library()') # debug('add_files_to_library()')
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)
_ = worker.signals.signal_finished.connect(self.load_music_table) _ = worker.signals.signal_finished.connect(self.load_music_table)
if self.qapp: if self.qapp:
threadpool = self.qapp.threadpool # type: ignore threadpool = self.qapp.threadpool # type: ignore
threadpool.start(worker) threadpool.start(worker)
else: else:
error("Application window could not be found") error("Application window could not be found")
def open_add_to_playlist_window(self): def open_add_to_playlist_window(self):
"""Opens a playlist choice menu and adds the currently selected files to the chosen playlist""" """Opens a playlist choice menu and adds the currently selected files to the chosen playlist"""
playlist_choice_window = AddToPlaylistWindow( playlist_choice_window = AddToPlaylistWindow(self.get_selected_songs_db_ids())
self.get_selected_songs_db_ids())
playlist_choice_window.exec_() playlist_choice_window.exec_()
def add_selected_songs_to_playlist(self, playlist_id): def add_selected_songs_to_playlist(self, playlist_id):
@ -680,7 +671,6 @@ class MusicTable(QTableView):
f"AddToPlaylistWindow.py save() | could not insert song into playlist: {e}" f"AddToPlaylistWindow.py save() | could not insert song into playlist: {e}"
) )
def delete_songs(self): def delete_songs(self):
"""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?
@ -695,11 +685,15 @@ class MusicTable(QTableView):
) )
reply = question_dialog.execute() reply = question_dialog.execute()
if reply: if reply:
worker = Worker(batch_delete_filepaths_from_playlist, selected_filepaths, self.selected_playlist_id) worker = Worker(
worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore batch_delete_filepaths_from_playlist,
selected_filepaths,
self.selected_playlist_id,
)
worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore
worker.signals.signal_finished.connect(self.delete_selected_row_indices) worker.signals.signal_finished.connect(self.delete_selected_row_indices)
if self.qapp: if self.qapp:
threadpool = self.qapp.threadpool # type: ignore threadpool = self.qapp.threadpool # type: ignore
threadpool.start(worker) threadpool.start(worker)
else: else:
question_dialog = QuestionBoxDetails( question_dialog = QuestionBoxDetails(
@ -709,11 +703,13 @@ class MusicTable(QTableView):
) )
reply = question_dialog.execute() reply = question_dialog.execute()
if reply: if reply:
worker = Worker(batch_delete_filepaths_from_database, selected_filepaths) worker = Worker(
worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore batch_delete_filepaths_from_database, selected_filepaths
)
worker.signals.signal_progress.connect(self.qapp.handle_progress) # type: ignore
worker.signals.signal_finished.connect(self.delete_selected_row_indices) worker.signals.signal_finished.connect(self.delete_selected_row_indices)
if self.qapp: if self.qapp:
threadpool = self.qapp.threadpool # type: ignore threadpool = self.qapp.threadpool # type: ignore
threadpool.start(worker) threadpool.start(worker)
# def delete_selected_row_indices(self): # def delete_selected_row_indices(self):
@ -752,7 +748,9 @@ class MusicTable(QTableView):
"""Opens a form with metadata from the selected audio files""" """Opens a form with metadata from the selected audio files"""
files = self.get_selected_songs_filepaths() files = self.get_selected_songs_filepaths()
song_ids = self.get_selected_songs_db_ids() song_ids = self.get_selected_songs_db_ids()
window = MetadataWindow(self.refreshMusicTableSignal, self.headers, files, song_ids) window = MetadataWindow(
self.refreshMusicTableSignal, self.headers, files, song_ids
)
window.refreshMusicTableSignal.connect(self.load_music_table) window.refreshMusicTableSignal.connect(self.load_music_table)
window.exec_() # Display the preferences window modally window.exec_() # Display the preferences window modally
@ -766,16 +764,18 @@ class MusicTable(QTableView):
def jump_to_current_song(self): def jump_to_current_song(self):
"""Moves screen to the currently playing song, then selects the row""" """Moves screen to the currently playing song, then selects the row"""
debug('jump_to_current_song()') debug("jump_to_current_song()")
# get the proxy model index # get the proxy model index
try: try:
proxy_index = self.proxymodel.mapFromSource(self.current_song_qmodel_index) proxy_index = self.proxymodel.mapFromSource(self.current_song_qmodel_index)
self.scrollTo(proxy_index) self.scrollTo(proxy_index)
self.selectRow(proxy_index.row()) self.selectRow(proxy_index.row())
except Exception as e: except Exception as e:
debug(f'jump_to_current_song() | {self.current_song_filepath}') debug(f"jump_to_current_song() | {self.current_song_filepath}")
debug(f'jump_to_current_song() | {self.current_song_qmodel_index}') debug(f"jump_to_current_song() | {self.current_song_qmodel_index}")
debug(f'jump_to_current_song() | Could not find current song in current table buffer - {e}') debug(
f"jump_to_current_song() | Could not find current song in current table buffer - {e}"
)
def open_directory(self): def open_directory(self):
"""Opens the containing directory of the currently selected song, in the system file manager""" """Opens the containing directory of the currently selected song, in the system file manager"""
@ -844,7 +844,7 @@ class MusicTable(QTableView):
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.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
def reorganize_files(self, filepaths, progress_callback=None): def reorganize_files(self, filepaths, progress_callback=None):
""" """
@ -864,7 +864,9 @@ class MusicTable(QTableView):
# Read file metadata # Read file metadata
artist, album = get_reorganize_vars(filepath) artist, album = get_reorganize_vars(filepath)
# 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)
)
# Need to determine if filepath is equal # Need to determine if filepath is equal
if new_path == filepath: if new_path == filepath:
continue continue
@ -904,10 +906,17 @@ class MusicTable(QTableView):
hint: You get a `playlist_id` from the signal emitted from PlaylistsPane as a tuple (1,) hint: You get a `playlist_id` from the signal emitted from PlaylistsPane as a tuple (1,)
""" """
# no playlist_id = library
# negative playlist id = builtin
if playlist_id: if playlist_id:
if self.selected_playlist_id == playlist_id[0]: if self.selected_playlist_id == playlist_id[0]:
# Don't reload if we clicked the same item # Don't reload if we clicked the same item
return return
self.selected_playlist_id = playlist_id[0]
elif self.selected_playlist_id is None:
self.selected_playlist_id = 0
self.model2.clear() self.model2.clear()
self.model2.setHorizontalHeaderLabels(self.headers.db_list) self.model2.setHorizontalHeaderLabels(self.headers.db_list)
fields = ", ".join(self.headers.db_list) fields = ", ".join(self.headers.db_list)
@ -917,18 +926,15 @@ class MusicTable(QTableView):
else "" else ""
) )
params = "" params = ""
is_playlist = 0
if len(playlist_id) > 0:
if playlist_id[0] == 0:
self.selected_playlist_id = 0
is_playlist = 0
else:
self.selected_playlist_id = playlist_id[0]
is_playlist = 1
else:
self.selected_playlist_id = 0
is_playlist = 0
# Check if actual playlist or library/builtin playlist
is_playlist = 0
if self.selected_playlist_id <= 0: # type: ignore
is_playlist = 0
else:
is_playlist = 1
# NOTE: caching gave almost no performance benefit on 20k song library
# try: # try:
# # Check cache for already loaded QTableView QStandardItemModel # # Check cache for already loaded QTableView QStandardItemModel
# data = self.data_cache[self.selected_playlist_id] # data = self.data_cache[self.selected_playlist_id]
@ -940,7 +946,8 @@ class MusicTable(QTableView):
try: try:
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
query = f"SELECT id, { query = f"SELECT id, {
fields} FROM song JOIN song_playlist sp ON id = sp.song_id WHERE sp.playlist_id = ?" fields
} FROM song JOIN song_playlist sp ON id = sp.song_id WHERE sp.playlist_id = ?"
# fulltext search # fulltext search
if self.search_string: if self.search_string:
# params = 3 * [self.search_string] # params = 3 * [self.search_string]
@ -949,8 +956,7 @@ class MusicTable(QTableView):
query = f"{query} WHERE {search_clause};" query = f"{query} WHERE {search_clause};"
else: else:
query = f"{query} AND {search_clause};" query = f"{query} AND {search_clause};"
data = db.query( data = db.query(query, (self.selected_playlist_id, params))
query, (self.selected_playlist_id, params))
else: else:
data = db.query(query, (self.selected_playlist_id,)) data = db.query(query, (self.selected_playlist_id,))
@ -966,13 +972,21 @@ class MusicTable(QTableView):
if self.search_string: if self.search_string:
params = ["%" + self.search_string + "%"] * 3 params = ["%" + self.search_string + "%"] * 3
if query.find("WHERE") == -1: if query.find("WHERE") == -1:
query = f"{query} WHERE {search_clause};" query = f"{query} WHERE {search_clause}"
else: else:
query = f"{query} AND {search_clause};" query = f"{query} AND {search_clause}"
# builtin playlist: newly added
if self.selected_playlist_id == -1:
newly_added_clause = f"date_added >= '{datetime.date.today() - datetime.timedelta(days=90)}'"
if query.find("WHERE") == -1:
query = f"{query} WHERE {newly_added_clause}"
else:
query = f"{query} AND {newly_added_clause}"
data = db.query( data = db.query(
query, query,
(params), (params),
) )
debug(query)
except Exception as e: except Exception as e:
error(f"load_music_table() | Unhandled exception 2: {e}") error(f"load_music_table() | Unhandled exception 2: {e}")
return return
@ -983,7 +997,9 @@ class MusicTable(QTableView):
self.current_playlist_id = self.selected_playlist_id self.current_playlist_id = self.selected_playlist_id
db_name: str = self.config.get("settings", "db").split("/").pop() db_name: str = self.config.get("settings", "db").split("/").pop()
db_filename = self.config.get("settings", "db") db_filename = self.config.get("settings", "db")
self.playlistStatsSignal.emit(f"Songs: {self.model2.rowCount()} | {db_name} | {db_filename}") self.playlistStatsSignal.emit(
f"Songs: {self.model2.rowCount()} | {db_name} | {db_filename}"
)
self.loadMusicTableSignal.emit() self.loadMusicTableSignal.emit()
self.find_current_and_selected_bits() self.find_current_and_selected_bits()
self.jump_to_current_song() self.jump_to_current_song()
@ -994,8 +1010,12 @@ class MusicTable(QTableView):
might as well get the selected song too i guess? though nothing should be selected when reloading the table data might as well get the selected song too i guess? though nothing should be selected when reloading the table data
""" """
search_col_num = self.headers.db_list.index("filepath") search_col_num = self.headers.db_list.index("filepath")
selected_qmodel_index = self.find_qmodel_index_by_value(self.proxymodel, search_col_num, self.selected_song_filepath) selected_qmodel_index = self.find_qmodel_index_by_value(
current_qmodel_index = self.find_qmodel_index_by_value(self.proxymodel, search_col_num, self.current_song_filepath) self.proxymodel, search_col_num, self.selected_song_filepath
)
current_qmodel_index = self.find_qmodel_index_by_value(
self.proxymodel, search_col_num, self.current_song_filepath
)
# Update the 2 proxy QModelIndexes that we track # Update the 2 proxy QModelIndexes that we track
self.set_selected_song_qmodel_index(selected_qmodel_index) self.set_selected_song_qmodel_index(selected_qmodel_index)
self.set_current_song_qmodel_index(current_qmodel_index) self.set_current_song_qmodel_index(current_qmodel_index)
@ -1022,9 +1042,8 @@ class MusicTable(QTableView):
item.setData(id, Qt.ItemDataRole.UserRole) item.setData(id, Qt.ItemDataRole.UserRole)
self.model2.appendRow(items) self.model2.appendRow(items)
def get_sort_config_by_name(self) -> list[tuple[str, Qt.SortOrder]]: def get_sort_config_by_name(self) -> list[tuple[str, Qt.SortOrder]]:
debug('get_sort_config_by_name()') debug("get_sort_config_by_name()")
sort_config = [] sort_config = []
raw_sort = self.sort_config raw_sort = self.sort_config
if not raw_sort: if not raw_sort:
@ -1049,7 +1068,7 @@ class MusicTable(QTableView):
""" """
Returns a list of (column_index, Qt.SortOrder) tuples based on config and headers Returns a list of (column_index, Qt.SortOrder) tuples based on config and headers
""" """
debug('get_sort_config_by_index()') debug("get_sort_config_by_index()")
sort_config = [] sort_config = []
raw_sort = self.sort_config raw_sort = self.sort_config
if not raw_sort: if not raw_sort:
@ -1079,7 +1098,7 @@ class MusicTable(QTableView):
""" """
Sorts the table using logical field names defined in config. Sorts the table using logical field names defined in config.
""" """
debug('sort_by_logical_fields()') debug("sort_by_logical_fields()")
sort_config = self.get_sort_config_by_index() sort_config = self.get_sort_config_by_index()
# Sort in reverse order (primary sort last) # Sort in reverse order (primary sort last)
for col_index, order in reversed(sort_config): for col_index, order in reversed(sort_config):
@ -1090,11 +1109,12 @@ class MusicTable(QTableView):
else: else:
continue continue
self.sortByColumn(col_index, new_order) self.sortByColumn(col_index, new_order)
debug(f'- sorted column index {col_index} by {new_order}') debug(f"- sorted column index {col_index} by {new_order}")
self.proxymodel.invalidateFilter() self.proxymodel.invalidateFilter()
def get_audio_files_recursively(
def get_audio_files_recursively(self, directories: list[str], progress_callback=None) -> list[str]: self, directories: list[str], progress_callback=None
) -> list[str]:
"""Scans a directories for files""" """Scans a directories for files"""
extensions = self.config.get("settings", "extensions").split(",") extensions = self.config.get("settings", "extensions").split(",")
audio_files: list[str] = [] audio_files: list[str] = []
@ -1139,7 +1159,10 @@ class MusicTable(QTableView):
return [] return []
selected_rows = set(index.row() for index in indexes) selected_rows = set(index.row() for index in indexes)
id_list = [ id_list = [
self.proxymodel.data(self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole) for row in selected_rows self.proxymodel.data(
self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole
)
for row in selected_rows
] ]
return id_list return id_list
@ -1163,9 +1186,13 @@ class MusicTable(QTableView):
except ValueError: except ValueError:
# if the user doesnt have filepath selected as a header, retrieve the file from db # if the user doesnt have filepath selected as a header, retrieve the file from db
row = self.currentIndex().row() row = self.currentIndex().row()
id = self.proxymodel.data(self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole) id = self.proxymodel.data(
self.proxymodel.index(row, 0), Qt.ItemDataRole.UserRole
)
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
filepath = db.query("SELECT filepath FROM song WHERE id = ?", (id,))[0][0] filepath = db.query("SELECT filepath FROM song WHERE id = ?", (id,))[0][
0
]
self.selected_song_filepath = filepath self.selected_song_filepath = filepath
def set_current_song_filepath(self, filepath=None) -> None: def set_current_song_filepath(self, filepath=None) -> None:
@ -1175,7 +1202,9 @@ class MusicTable(QTableView):
""" """
# update the filepath # update the filepath
if not filepath: if not filepath:
path = self.current_song_qmodel_index.siblingAtColumn(self.headers.db_list.index("filepath")).data() path = self.current_song_qmodel_index.siblingAtColumn(
self.headers.db_list.index("filepath")
).data()
self.current_song_filepath: str = path self.current_song_filepath: str = path
else: else:
self.current_song_filepath = filepath self.current_song_filepath = filepath
@ -1234,7 +1263,6 @@ class MusicTable(QTableView):
pass pass
# QT Roles # QT Roles
# In Qt, roles are used to specify different aspects or types of data associated with each item in a model. The roles are defined in the Qt.ItemDataRole enum. The three roles you asked about - DisplayRole, EditRole, and UserRole - are particularly important. Let's break them down: # In Qt, roles are used to specify different aspects or types of data associated with each item in a model. The roles are defined in the Qt.ItemDataRole enum. The three roles you asked about - DisplayRole, EditRole, and UserRole - are particularly important. Let's break them down:

View File

@ -43,7 +43,7 @@ class PlaylistsPane(QTreeWidget):
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.showContextMenu) self.customContextMenuRequested.connect(self.showContextMenu)
self.currentItemChanged.connect(self.playlist_clicked) self.currentItemChanged.connect(self.playlist_clicked)
self.playlist_db_id_choice: int | None = None self.playlist_db_id_choice: int = 0
font: QFont = QFont() font: QFont = QFont()
font.setPointSize(14) font.setPointSize(14)
font.setBold(False) font.setBold(False)
@ -62,6 +62,8 @@ class PlaylistsPane(QTreeWidget):
"SELECT id, name FROM playlist ORDER BY date_created DESC;", () "SELECT id, name FROM playlist ORDER BY date_created DESC;", ()
) )
# debug(f'PlaylistsPane: | playlists = {playlists}') # debug(f'PlaylistsPane: | playlists = {playlists}')
branch = PlaylistWidgetItem(self, -1, "newly added")
self._playlists_root.addChild(branch)
for playlist in playlists: for playlist in playlists:
branch = PlaylistWidgetItem(self, playlist[0], playlist[1]) branch = PlaylistWidgetItem(self, playlist[0], playlist[1])
self._playlists_root.addChild(branch) self._playlists_root.addChild(branch)
@ -71,7 +73,7 @@ class PlaylistsPane(QTreeWidget):
def showContextMenu(self, position: QPoint): def showContextMenu(self, position: QPoint):
"""Right-click context menu""" """Right-click context menu"""
menu = QMenu(self) menu = QMenu(self)
if self.playlist_db_id_choice is not None: if self.playlist_db_id_choice > 0:
# only allow delete/rename non-root nodes # only allow delete/rename non-root nodes
rename_action = QAction("Rename", self) rename_action = QAction("Rename", self)
delete_action = QAction("Delete", self) delete_action = QAction("Delete", self)
@ -135,20 +137,23 @@ class PlaylistsPane(QTreeWidget):
if self.qapp: if self.qapp:
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
db.execute( db.execute(
"DELETE FROM playlist WHERE id = ?;", (self.playlist_db_id_choice,) "DELETE FROM playlist WHERE id = ?;",
(self.playlist_db_id_choice,),
) )
# reload # reload
worker = Worker(self.reload_playlists) worker = Worker(self.reload_playlists)
threadpool = self.qapp.threadpool threadpool = self.qapp.threadpool
threadpool.start(worker) threadpool.start(worker)
else: else:
error_dialog = ErrorDialog("Main application [qapp] not loaded - aborting operation") error_dialog = ErrorDialog(
"Main application [qapp] not loaded - aborting operation"
)
error_dialog.exec() error_dialog.exec()
def playlist_clicked(self, item): def playlist_clicked(self, item):
"""Specific playlist pane index was clicked""" """Specific playlist pane index was clicked"""
if item == self._playlists_root or item == self._library_root: if item == self._playlists_root or item == self._library_root:
self.playlist_db_id_choice = None self.playlist_db_id_choice = 0
# self.all_songs_selected() # self.all_songs_selected()
self.playlistChoiceSignal.emit(0) self.playlistChoiceSignal.emit(0)
elif isinstance(item, PlaylistWidgetItem): elif isinstance(item, PlaylistWidgetItem):

120
main.py
View File

@ -7,8 +7,10 @@ import qdarktheme
from PyQt5.QtGui import QFontDatabase from PyQt5.QtGui import QFontDatabase
from PyQt5 import QtCore from PyQt5 import QtCore
from subprocess import run from subprocess import run
# from pyqtgraph import mkBrush # from pyqtgraph import mkBrush
from mutagen.id3 import ID3 from mutagen.id3 import ID3
# from mutagen.id3._frames import APIC # from mutagen.id3._frames import APIC
from configparser import ConfigParser from configparser import ConfigParser
from pathlib import Path from pathlib import Path
@ -49,7 +51,7 @@ from utils import (
set_album_art, set_album_art,
id3_remap, id3_remap,
get_album_art, get_album_art,
Worker Worker,
) )
from components import ( from components import (
MediaPlayer, MediaPlayer,
@ -59,7 +61,7 @@ from components import (
CreatePlaylistWindow, CreatePlaylistWindow,
ExportPlaylistWindow, ExportPlaylistWindow,
HeaderTags2, HeaderTags2,
DebugWindow DebugWindow,
) )
from utils.export_playlist_by_id import export_playlist_by_id from utils.export_playlist_by_id import export_playlist_by_id
@ -100,13 +102,17 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
# widget bits # widget bits
self.tableView: MusicTable self.tableView: MusicTable
self.tableView.saved_column_ratios: list[str] = str(self.config["table"]["column_ratios"]).split(",") # type: ignore self.tableView.saved_column_ratios: list[str] = str(
self.config["table"]["column_ratios"]
).split(",") # type: ignore
self.album_art_scene: QGraphicsScene = QGraphicsScene() self.album_art_scene: QGraphicsScene = QGraphicsScene()
self.player: QMediaPlayer = MediaPlayer() self.player: QMediaPlayer = MediaPlayer()
# set index on choose song # set index on choose song
# index is the model2's row number? i guess? # index is the model2's row number? i guess?
self.probe: QAudioProbe = QAudioProbe() # Gets audio buffer data self.probe: QAudioProbe = QAudioProbe() # Gets audio buffer data
self.audio_visualizer: AudioVisualizer = AudioVisualizer(self.player, self.probe, self.PlotWidget) self.audio_visualizer: AudioVisualizer = AudioVisualizer(
self.player, self.probe, self.PlotWidget
)
self.timer: QTimer = QTimer(parent=self) # for playback slider and such self.timer: QTimer = QTimer(parent=self) # for playback slider and such
# Button styles # Button styles
@ -147,10 +153,14 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.albumGraphicsView.setFixedSize(250, 250) self.albumGraphicsView.setFixedSize(250, 250)
# Connections # Connections
self.playbackSlider.sliderReleased.connect(lambda: self.player.setPosition(self.playbackSlider.value())) # sliderReleased works better than sliderMoved self.playbackSlider.sliderReleased.connect(
lambda: self.player.setPosition(self.playbackSlider.value())
) # sliderReleased works better than sliderMoved
self.volumeSlider.sliderMoved[int].connect(lambda: self.on_volume_changed()) self.volumeSlider.sliderMoved[int].connect(lambda: self.on_volume_changed())
self.speedSlider.sliderMoved.connect(lambda: self.on_speed_changed(self.speedSlider.value())) self.speedSlider.sliderMoved.connect(
#self.speedSlider.doubleClicked.connect(lambda: self.on_speed_changed(1)) lambda: self.on_speed_changed(self.speedSlider.value())
)
# self.speedSlider.doubleClicked.connect(lambda: self.on_speed_changed(1))
self.playButton.clicked.connect(self.on_play_clicked) # Click to play/pause self.playButton.clicked.connect(self.on_play_clicked) # Click to play/pause
self.previousButton.clicked.connect(self.on_prev_clicked) self.previousButton.clicked.connect(self.on_prev_clicked)
self.nextButton.clicked.connect(self.on_next_clicked) # Click to next song self.nextButton.clicked.connect(self.on_next_clicked) # Click to next song
@ -162,10 +172,9 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
# EDIT MENU # EDIT MENU
self.actionPreferences.triggered.connect(self.open_preferences) self.actionPreferences.triggered.connect(self.open_preferences)
# VIEW MENU # VIEW MENU
self.actionFontListing.triggered.connect(self.open_font_listing) self.actionFontListing.triggered.connect(self.open_font_listing)
QFontDatabase().families() QFontDatabase().families()
# QUICK ACTIONS MENU # QUICK ACTIONS MENU
self.actionScanLibraries.triggered.connect(self.scan_libraries) self.actionScanLibraries.triggered.connect(self.scan_libraries)
self.actionDeleteDatabase.triggered.connect(self.delete_database) self.actionDeleteDatabase.triggered.connect(self.delete_database)
@ -182,21 +191,30 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.searchLineEdit.textTypedSignal.connect(self.handle_search_box_text) self.searchLineEdit.textTypedSignal.connect(self.handle_search_box_text)
# tableView # tableView
self.tableView.playSignal.connect(self.play_audio_file) self.tableView.playSignal.connect(self.play_audio_file)
self.tableView.playPauseSignal.connect(self.on_play_clicked) # Spacebar toggle play/pause signal self.tableView.playPauseSignal.connect(
self.on_play_clicked
) # Spacebar toggle play/pause signal
self.tableView.handleProgressSignal.connect(self.handle_progress) self.tableView.handleProgressSignal.connect(self.handle_progress)
self.tableView.searchBoxSignal.connect(self.handle_search_box_visibility) self.tableView.searchBoxSignal.connect(self.handle_search_box_visibility)
self.tableView.playlistStatsSignal.connect(self.set_permanent_status_bar_message) self.tableView.playlistStatsSignal.connect(
self.set_permanent_status_bar_message
)
self.tableView.load_music_table() self.tableView.load_music_table()
self.player.playlistNextSignal.connect(self.on_next_clicked) self.player.playlistNextSignal.connect(self.on_next_clicked)
# playlistTreeView # playlistTreeView
self.playlistTreeView.playlistChoiceSignal.connect(self.tableView.load_music_table) self.playlistTreeView.playlistChoiceSignal.connect(
self.tableView.load_music_table
)
self.playlistTreeView.allSongsSignal.connect(self.tableView.load_music_table) self.playlistTreeView.allSongsSignal.connect(self.tableView.load_music_table)
# albumGraphicsView # albumGraphicsView
self.albumGraphicsView.albumArtDropped.connect(self.set_album_art_for_selected_songs) self.albumGraphicsView.albumArtDropped.connect(
self.albumGraphicsView.albumArtDeleted.connect(self.delete_album_art_for_current_song) self.set_album_art_for_selected_songs
)
self.albumGraphicsView.albumArtDeleted.connect(
self.delete_album_art_for_current_song
)
def nothing(self): def nothing(self):
pass pass
@ -216,8 +234,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def closeEvent(self, a0: QCloseEvent | None) -> None: def closeEvent(self, a0: QCloseEvent | None) -> None:
"""Save settings when closing the application""" """Save settings when closing the application"""
self.config["settings"]["volume"] = str(self.current_volume) self.config["settings"]["volume"] = str(self.current_volume)
self.config["settings"]["window_size"] = (str(self.width()) + "," + str(self.height())) self.config["settings"]["window_size"] = (
self.config['table']['column_ratios'] = ",".join(self.tableView.get_current_header_width_ratios()) str(self.width()) + "," + str(self.height())
)
self.config["table"]["column_ratios"] = ",".join(
self.tableView.get_current_header_width_ratios()
)
self.config["table"]["sort_order"] = self.tableView.sort_config self.config["table"]["sort_order"] = self.tableView.sort_config
# Save the config # Save the config
try: try:
@ -225,10 +247,13 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.config.write(configfile) self.config.write(configfile)
except Exception as e: except Exception as e:
debug(f"wtf man {e}") debug(f"wtf man {e}")
debug("Done writing config")
# auto export any playlists that want it # auto export any playlists that want it
try: try:
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
result = db.query('SELECT id FROM playlist WHERE auto_export = true;', ()) result = db.query(
"SELECT id FROM playlist WHERE auto_export = true;", ()
)
ids = [id[0] for id in result] ids = [id[0] for id in result]
for id in ids: for id in ids:
export_playlist_by_id(id) export_playlist_by_id(id)
@ -298,8 +323,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
return return
row: int = index.row() row: int = index.row()
prev_row: int = row - 1 prev_row: int = row - 1
prev_index: QModelIndex = self.tableView.proxymodel.index(prev_row, index.column()) prev_index: QModelIndex = self.tableView.proxymodel.index(
prev_filepath = prev_index.siblingAtColumn(self.headers.db_list.index("filepath")).data() prev_row, index.column()
)
prev_filepath = prev_index.siblingAtColumn(
self.headers.db_list.index("filepath")
).data()
if prev_filepath is None: if prev_filepath is None:
return return
@ -318,8 +347,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
return return
row: int = index.row() row: int = index.row()
next_row: int = row + 1 next_row: int = row + 1
next_index: QModelIndex = self.tableView.proxymodel.index(next_row, index.column()) next_index: QModelIndex = self.tableView.proxymodel.index(
next_filepath = next_index.siblingAtColumn(self.headers.db_list.index("filepath")).data() next_row, index.column()
)
next_filepath = next_index.siblingAtColumn(
self.headers.db_list.index("filepath")
).data()
if next_filepath is None: if next_filepath is None:
return return
self.play_audio_file(next_filepath) self.play_audio_file(next_filepath)
@ -368,8 +401,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def load_config(self) -> None: def load_config(self) -> None:
"""does what it says""" """does what it says"""
cfg_file = ( cfg_file = (
Path(user_config_dir(appname="musicpom", Path(user_config_dir(appname="musicpom", appauthor="billypom"))
appauthor="billypom")) / "config.ini" / "config.ini"
) )
self.config.read(cfg_file) self.config.read(cfg_file)
debug("load_config()") debug("load_config()")
@ -447,7 +480,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
for song in selected_songs: for song in selected_songs:
debug( debug(
f"main.py set_album_art_for_selected_songs() | updating album art for { f"main.py set_album_art_for_selected_songs() | updating album art for {
song}" song
}"
) )
set_album_art(song, album_art_path) set_album_art(song, album_art_path)
@ -474,8 +508,7 @@ 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( current_minutes, current_seconds = divmod(slider_position / 1000, 60)
slider_position / 1000, 60)
duration_minutes, duration_seconds = divmod( duration_minutes, duration_seconds = divmod(
self.player.duration() / 1000, 60 self.player.duration() / 1000, 60
) )
@ -494,6 +527,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
""" """
updates the status bar when progress is emitted updates the status bar when progress is emitted
""" """
# breakpoint()
self.show_status_bar_message(data) self.show_status_bar_message(data)
# ____________________ # ____________________
@ -526,8 +560,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def create_playlist(self) -> None: def create_playlist(self) -> None:
"""Creates a database record for a playlist, given a name""" """Creates a database record for a playlist, given a name"""
window = CreatePlaylistWindow(self.playlistCreatedSignal) window = CreatePlaylistWindow(self.playlistCreatedSignal)
window.playlistCreatedSignal.connect( window.playlistCreatedSignal.connect(self.add_latest_playlist_to_tree) # type: ignore
self.add_latest_playlist_to_tree) # type: ignore
window.exec_() window.exec_()
def import_playlist(self) -> None: def import_playlist(self) -> None:
@ -592,22 +625,21 @@ def update_database_file() -> bool:
Reads the database file (specified by config file) Reads the database file (specified by config file)
""" """
cfg_file = ( cfg_file = (
Path(user_config_dir(appname="musicpom", Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini"
appauthor="billypom")) / "config.ini"
) )
cfg_path = str(Path(user_config_dir( cfg_path = str(Path(user_config_dir(appname="musicpom", appauthor="billypom")))
appname="musicpom", appauthor="billypom")))
config = ConfigParser() config = ConfigParser()
config.read(cfg_file) config.read(cfg_file)
db_filepath: str db_filepath: str
db_filepath= config.get("settings", "db") db_filepath = config.get("settings", "db")
# If the database location isnt set at the config location, move it # If the database location isnt set at the config location, move it
if not db_filepath.startswith(cfg_path): if not db_filepath.startswith(cfg_path):
new_path = f"{cfg_path}/{db_filepath}" new_path = f"{cfg_path}/{db_filepath}"
debug( debug(
f"Set new config [db] database path: \n> Current: { f"Set new config [db] database path: \n> Current: {db_filepath}\n> New:{
db_filepath}\n> New:{new_path}" new_path
}"
) )
config["settings"]["db"] = new_path config["settings"]["db"] = new_path
# Save the config # Save the config
@ -641,11 +673,9 @@ def update_config_file() -> ConfigParser:
If the user config file is not up to date, update it with examples from sample config If the user config file is not up to date, update it with examples from sample config
""" """
cfg_file = ( cfg_file = (
Path(user_config_dir(appname="musicpom", Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini"
appauthor="billypom")) / "config.ini"
) )
cfg_path = str(Path(user_config_dir( cfg_path = str(Path(user_config_dir(appname="musicpom", appauthor="billypom")))
appname="musicpom", appauthor="billypom")))
# If config path doesn't exist, create it # If config path doesn't exist, create it
if not os.path.exists(cfg_path): if not os.path.exists(cfg_path):
@ -693,11 +723,11 @@ if __name__ == "__main__":
format="{%(filename)s:%(lineno)d} %(levelname)s - %(message)s", format="{%(filename)s:%(lineno)d} %(levelname)s - %(message)s",
handlers=handlers, handlers=handlers,
) )
debug('--------- musicpom debug started') debug("--------- musicpom debug started")
debug('--------------------------------') debug("--------------------------------")
debug(handlers) debug(handlers)
debug('--------------------------------') debug("--------------------------------")
debug('--------------------------------') debug("--------------------------------")
# Initialization # Initialization
config: ConfigParser = update_config_file() config: ConfigParser = update_config_file()
if not update_database_file(): if not update_database_file():
@ -713,7 +743,7 @@ if __name__ == "__main__":
try: try:
# Dark theme >:3 # Dark theme >:3
qdarktheme.setup_theme() qdarktheme.setup_theme()
#qdarktheme.setup_theme("auto") # this is supposed to work but doesnt # qdarktheme.setup_theme("auto") # this is supposed to work but doesnt
except Exception: except Exception:
pass pass
# Show the UI # Show the UI

View File

@ -4,7 +4,7 @@ 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
from .get_tags import get_tags, id3_remap from .get_tags import get_tags, id3_remap
from .get_reorganize_vars import get_reorganize_vars 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_database import batch_delete_filepaths_from_database from .batch_delete_filepaths_from_database import batch_delete_filepaths_from_database

View File

@ -1,26 +1,31 @@
from ast import parse
import os import os
from PyQt5.QtCore import QThreadPool from PyQt5.QtCore import QThreadPool
import DBA import DBA
import logging import logging
from utils import get_reorganize_vars, Worker from utils import get_reorganize_vars, Worker, parse_artist_album
from pathlib import Path
from time import time
def export_playlist_by_id(playlist_db_id: int) -> bool: def export_playlist_by_id(playlist_db_id: int) -> bool:
""" """
Exports a playlist to its defined auto_export_path, by database ID Exports a playlist to its defined auto_export_path, by database ID
""" """
logging.debug(f"Exporting playlist id: {playlist_db_id}")
threadpool = QThreadPool() threadpool = QThreadPool()
try: try:
with DBA.DBAccess() as db: with DBA.DBAccess() as db:
result = db.query(''' result = db.query(
"""
SELECT auto_export_path, path_prefix FROM playlist WHERE id = ? SELECT auto_export_path, path_prefix FROM playlist WHERE id = ?
''', (playlist_db_id,) """,
) (playlist_db_id,),
)
auto_export_path = result[0][0] auto_export_path = result[0][0]
path_prefix = result[0][1] path_prefix = result[0][1]
except Exception as e: except Exception as e:
logging.error( logging.error(f"export_playlist_by_id.py | Could not contact database: {e}")
f"export_playlist_by_id.py | Could not contact database: {e}"
)
return False return False
# If the prefix is null, then use an empty string # If the prefix is null, then use an empty string
@ -40,7 +45,7 @@ def export_playlist_by_id(playlist_db_id: int) -> bool:
WHERE sp.playlist_id = ?;""", WHERE sp.playlist_id = ?;""",
(playlist_db_id,), (playlist_db_id,),
) )
db_paths = [path[0] for path in data] db_paths = [Path(path[0]) for path in data]
except Exception as e: except Exception as e:
logging.error( logging.error(
f"export_playlist_by_id.py | Could not retrieve records from playlist: {e}" f"export_playlist_by_id.py | Could not retrieve records from playlist: {e}"
@ -50,13 +55,11 @@ def export_playlist_by_id(playlist_db_id: int) -> bool:
# Gather playlist song paths # Gather playlist song paths
write_paths = [] write_paths = []
# Relative paths # Relative paths
logging.debug("Creating relative paths...")
for song in db_paths: for song in db_paths:
artist, album = get_reorganize_vars(song) artist, album = parse_artist_album(song)
write_path = os.path.join( write_path = Path(path_prefix) / artist / album / song.name
path_prefix, artist, album, song.split("/")[-1] + "\n" write_paths.append(str(write_path) + "\n")
)
write_paths.append(str(write_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)
@ -64,10 +67,15 @@ def export_playlist_by_id(playlist_db_id: int) -> bool:
threadpool.start(worker) threadpool.start(worker)
return True return True
def write_to_playlist_file(paths: list[str], outfile: str, progress_callback=None) -> None:
def write_to_playlist_file(
paths: list[str], outfile: str, progress_callback=None
) -> None:
""" """
Writes a list of strings to a m3u file Writes a list of strings to a m3u file
""" """
os.makedirs(os.path.dirname(outfile), exist_ok=True) os.makedirs(os.path.dirname(outfile), exist_ok=True)
logging.debug("Writing paths to m3u file")
with open(outfile, "w") as f: with open(outfile, "w") as f:
f.writelines(paths) f.writelines(paths)
logging.debug("Done writing m3u files")

View File

@ -1,7 +1,8 @@
from mutagen.id3 import ID3 from mutagen.id3 import ID3
from pathlib import Path
def get_reorganize_vars(filepath: str) -> tuple[str, str]: def get_reorganize_vars(filepath: Path) -> tuple[str, str]:
""" """
Takes in a path to an audio file Takes in a path to an audio file
returns the (artist, album) as a tuple of strings returns the (artist, album) as a tuple of strings
@ -9,6 +10,7 @@ def get_reorganize_vars(filepath: str) -> tuple[str, str]:
if no artist or album or ID3 tags at all are found, if no artist or album or ID3 tags at all are found,
function will return ("Unknown Artist", "Unknown Album") function will return ("Unknown Artist", "Unknown Album")
""" """
# logging.debug(f"getting reorganize vars for {filepath}")
# TODO: fix this func. id3_remap(get_tags()) # TODO: fix this func. id3_remap(get_tags())
# or is what i have less memory so more better? :shrug: # or is what i have less memory so more better? :shrug:
audio = ID3(filepath) audio = ID3(filepath)
@ -27,3 +29,78 @@ def get_reorganize_vars(filepath: str) -> tuple[str, str]:
album = "Unknown Album" album = "Unknown Album"
return artist, album return artist, album
def synchsafe_to_int(b):
"""Convert 4 synchsafe bytes to int (ID3v2.4)."""
return (b[0] << 21) | (b[1] << 14) | (b[2] << 7) | b[3]
def decode_text_frame(data):
"""Decode ID3 text frame payload."""
if not data:
return None
encoding_byte = data[0]
text_bytes = data[1:]
if encoding_byte == 0:
return text_bytes.decode("iso-8859-1", errors="replace").rstrip("\x00")
elif encoding_byte == 1:
return text_bytes.decode("utf-16", errors="replace").rstrip("\x00")
elif encoding_byte == 2:
return text_bytes.decode("utf-16-be", errors="replace").rstrip("\x00")
elif encoding_byte == 3:
return text_bytes.decode("utf-8", errors="replace").rstrip("\x00")
else:
return None
def parse_artist_album(filepath) -> tuple[str, str]:
with open(filepath, "rb") as f:
header = f.read(10)
if len(header) < 10 or header[0:3] != b"ID3":
raise ValueError("No ID3v2 tag found")
version_major = header[3]
tag_size = synchsafe_to_int(header[6:10])
artist = None
album = None
start_pos = f.tell()
while f.tell() - start_pos < tag_size:
frame_header = f.read(10)
if len(frame_header) < 10:
break
frame_id = frame_header[0:4].decode("ascii", errors="replace")
# Stop at padding
if frame_id.strip("\x00") == "":
break
if version_major == 4:
frame_size = synchsafe_to_int(frame_header[4:8])
else:
frame_size = int.from_bytes(frame_header[4:8], "big")
# Skip flags (bytes 89 already included in header)
frame_data = f.read(frame_size)
if frame_id == "TPE1":
artist = decode_text_frame(frame_data)
elif frame_id == "TALB":
album = decode_text_frame(frame_data)
if artist and album:
break
if not artist:
artist = "Unknown Arist"
if not album:
album = "Unknown Album"
return artist, album