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,
get_tags,
set_tag,
Worker
Worker,
)
from subprocess import Popen
from logging import debug, error
import os
import shutil
import typing
from pathlib import Path
from appdirs import user_config_dir
from configparser import ConfigParser
import os
import shutil
import typing
import datetime
class MusicTable(QTableView):
playlistStatsSignal: pyqtSignal = pyqtSignal(str)
@ -83,7 +85,7 @@ class MusicTable(QTableView):
/ "config.ini"
)
_ = 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]] = []
font: QFont = QFont()
@ -135,8 +137,12 @@ class MusicTable(QTableView):
self.horizontal_header.setSectionResizeMode(QHeaderView.Interactive)
# self.horizontal_header.sortIndicatorChanged.connect(self.on_user_sort_change)
self.horizontal_header.sectionClicked.connect(self.on_header_clicked)
self.horizontal_header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.horizontal_header.customContextMenuRequested.connect(self.show_header_context_menu)
self.horizontal_header.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self.horizontal_header.customContextMenuRequested.connect(
self.show_header_context_menu
)
# dumb vertical estupido
self.vertical_header: QHeaderView = self.verticalHeader()
assert self.vertical_header is not None
@ -173,7 +179,6 @@ class MusicTable(QTableView):
"""
self.focusLeaveSignal.emit()
def resizeEvent(self, e: typing.Optional[QResizeEvent]) -> None:
"""
Do something when the QTableView is resized
@ -194,9 +199,11 @@ class MusicTable(QTableView):
super().showEvent(a0)
widths = []
for _ in self.saved_column_ratios:
widths.append('0.001')
widths.append("0.001")
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):
"""Override paint event to highlight the current cell"""
@ -223,7 +230,6 @@ class MusicTable(QTableView):
menu = QMenu(self)
menu.setFont(font)
# OLD CODE but i keep cus i sais so
# add_to_playlist_action = QAction("Add to playlist", self)
# _ = 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:
action = QAction(playlist_name, add_to_playlist_menu)
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)
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)
menu.addAction(jump_to_current_song_action)
# open in file explorer
open_containing_folder_action = QAction(
"Open in system file manager", self)
open_containing_folder_action = QAction("Open in system file manager", self)
_ = open_containing_folder_action.triggered.connect(self.open_directory)
menu.addAction(open_containing_folder_action)
# view id3 tags (debug)
@ -309,7 +315,9 @@ class MusicTable(QTableView):
if directories:
worker = Worker(self.get_audio_files_recursively, directories)
_ = 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)
if self.qapp:
threadpool = self.qapp.threadpool # type: ignore
@ -372,7 +380,6 @@ class MusicTable(QTableView):
else: # Default behavior
super().keyPressEvent(e)
# ____________________
# | |
# | |
@ -387,7 +394,6 @@ class MusicTable(QTableView):
self.find_current_and_selected_bits()
self.jump_to_selected_song()
def on_cell_clicked(self, index):
"""
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()]
# debug(f"on_cell_data_changed | edited column name: {edited_column_name}")
response = set_tag(
filepath=filepath,
db_column=edited_column_name,
value=user_input_data
filepath=filepath, db_column=edited_column_name, value=user_input_data
)
if response:
# Update the library with new metadata
_ = update_song_in_database(song_id, edited_column_name, user_input_data)
else:
error('ERROR: response failed')
error("ERROR: response failed")
return
def on_get_audio_files_recursively_finished(self, result: list[str]):
"""file search completion handler"""
if result:
@ -448,8 +451,7 @@ class MusicTable(QTableView):
except IndexError:
pass
except Exception as e:
debug(
f"on_add_files_to_database_finished() | Something went wrong: {e}")
debug(f"on_add_files_to_database_finished() | Something went wrong: {e}")
# ____________________
# | |
@ -458,7 +460,6 @@ class MusicTable(QTableView):
# | |
# |____________________|
def handle_progress(self, data: object):
"""Emits data to main"""
self.handleProgressSignal.emit(data)
@ -484,17 +485,13 @@ class MusicTable(QTableView):
sort_asc_action = QAction("Sort ASC", self)
sort_asc_action.triggered.connect(
lambda checked=False: self.mark_column_sort(
logical_index,
column_name,
Qt.SortOrder.AscendingOrder
logical_index, column_name, Qt.SortOrder.AscendingOrder
)
)
sort_desc_action = QAction("Sort DESC", self)
sort_desc_action.triggered.connect(
lambda checked=False: self.mark_column_sort(
logical_index,
column_name,
Qt.SortOrder.DescendingOrder
logical_index, column_name, Qt.SortOrder.DescendingOrder
)
)
menu.addAction(clear_action)
@ -515,7 +512,9 @@ class MusicTable(QTableView):
# write field name and the sort direction into config
# 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
# Clear the sort order list
self.column_sort_order = []
@ -538,12 +537,13 @@ class MusicTable(QTableView):
else:
direction = None
self.column_sort_order.append((field, direction))
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)
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
)
# Re-apply the updated sort order
self.sort_by_logical_fields()
def clear_all_sorts(self):
"""
Clears all stored sort orders and refreshes the table view.
@ -551,7 +551,6 @@ class MusicTable(QTableView):
# Clear sort visually
self.horizontal_header.setSortIndicator(-1, Qt.SortOrder.AscendingOrder)
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
@ -563,7 +562,7 @@ class MusicTable(QTableView):
return QModelIndex()
if column > model.columnCount():
return model.index(0,0)
return model.index(0, 0)
if value:
for row in range(model.rowCount()):
@ -571,8 +570,7 @@ class MusicTable(QTableView):
if index.data() == value:
return index
return model.index(0,0)
return model.index(0, 0)
def get_current_header_width_ratios(self) -> list[str]:
"""
@ -589,7 +587,6 @@ class MusicTable(QTableView):
# debug(f'get_current_header_width_ratios = {column_ratios}')
return column_ratios
def save_header_ratios(self):
"""
Saves the current header widths to memory and file, as ratios
@ -607,7 +604,6 @@ class MusicTable(QTableView):
debug(f"wtf man {e}")
# debug(f"Saved column ratios: {self.saved_column_ratios}")
def load_header_widths(self, ratios: list[str] | None = None):
"""
Loads the header widths, based on saved ratios
@ -623,15 +619,13 @@ class MusicTable(QTableView):
for ratio in column_ratios:
column_widths.append(float(ratio) * total_table_width)
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
self.setColumnWidth(i, int(column_widths[i]))
def set_qmodel_index(self, index: QModelIndex):
self.current_song_qmodel_index = index
def play_selected_audio_file(self):
"""
Sets the current song filepath
@ -641,7 +635,6 @@ class MusicTable(QTableView):
self.set_current_song_filepath()
self.playSignal.emit(self.current_song_filepath)
def add_files_to_library(self, files: list[str]) -> None:
"""
Spawns a worker thread - adds a list of filepaths to the library
@ -660,11 +653,9 @@ class MusicTable(QTableView):
else:
error("Application window could not be found")
def open_add_to_playlist_window(self):
"""Opens a playlist choice menu and adds the currently selected files to the chosen playlist"""
playlist_choice_window = AddToPlaylistWindow(
self.get_selected_songs_db_ids())
playlist_choice_window = AddToPlaylistWindow(self.get_selected_songs_db_ids())
playlist_choice_window.exec_()
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}"
)
def delete_songs(self):
"""Asks to delete the currently selected songs from the db and music table (not the filesystem)"""
# NOTE: provide extra questionbox option?
@ -695,7 +685,11 @@ class MusicTable(QTableView):
)
reply = question_dialog.execute()
if reply:
worker = Worker(batch_delete_filepaths_from_playlist, selected_filepaths, self.selected_playlist_id)
worker = Worker(
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)
if self.qapp:
@ -709,7 +703,9 @@ class MusicTable(QTableView):
)
reply = question_dialog.execute()
if reply:
worker = Worker(batch_delete_filepaths_from_database, selected_filepaths)
worker = Worker(
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)
if self.qapp:
@ -752,7 +748,9 @@ class MusicTable(QTableView):
"""Opens a form with metadata from the selected audio files"""
files = self.get_selected_songs_filepaths()
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.exec_() # Display the preferences window modally
@ -766,16 +764,18 @@ class MusicTable(QTableView):
def jump_to_current_song(self):
"""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
try:
proxy_index = self.proxymodel.mapFromSource(self.current_song_qmodel_index)
self.scrollTo(proxy_index)
self.selectRow(proxy_index.row())
except Exception as e:
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() | Could not find current song in current table buffer - {e}')
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() | Could not find current song in current table buffer - {e}"
)
def open_directory(self):
"""Opens the containing directory of the currently selected song, in the system file manager"""
@ -864,7 +864,9 @@ class MusicTable(QTableView):
# Read file metadata
artist, album = get_reorganize_vars(filepath)
# 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
if new_path == filepath:
continue
@ -904,10 +906,17 @@ class MusicTable(QTableView):
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 self.selected_playlist_id == playlist_id[0]:
# Don't reload if we clicked the same item
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.setHorizontalHeaderLabels(self.headers.db_list)
fields = ", ".join(self.headers.db_list)
@ -917,18 +926,15 @@ class MusicTable(QTableView):
else ""
)
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:
# # Check cache for already loaded QTableView QStandardItemModel
# data = self.data_cache[self.selected_playlist_id]
@ -940,7 +946,8 @@ class MusicTable(QTableView):
try:
with DBA.DBAccess() as db:
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
if self.search_string:
# params = 3 * [self.search_string]
@ -949,8 +956,7 @@ class MusicTable(QTableView):
query = f"{query} WHERE {search_clause};"
else:
query = f"{query} AND {search_clause};"
data = db.query(
query, (self.selected_playlist_id, params))
data = db.query(query, (self.selected_playlist_id, params))
else:
data = db.query(query, (self.selected_playlist_id,))
@ -966,13 +972,21 @@ class MusicTable(QTableView):
if self.search_string:
params = ["%" + self.search_string + "%"] * 3
if query.find("WHERE") == -1:
query = f"{query} WHERE {search_clause};"
query = f"{query} WHERE {search_clause}"
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(
query,
(params),
)
debug(query)
except Exception as e:
error(f"load_music_table() | Unhandled exception 2: {e}")
return
@ -983,7 +997,9 @@ class MusicTable(QTableView):
self.current_playlist_id = self.selected_playlist_id
db_name: str = self.config.get("settings", "db").split("/").pop()
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.find_current_and_selected_bits()
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
"""
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)
current_qmodel_index = self.find_qmodel_index_by_value(self.proxymodel, search_col_num, self.current_song_filepath)
selected_qmodel_index = self.find_qmodel_index_by_value(
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
self.set_selected_song_qmodel_index(selected_qmodel_index)
self.set_current_song_qmodel_index(current_qmodel_index)
@ -1022,9 +1042,8 @@ class MusicTable(QTableView):
item.setData(id, Qt.ItemDataRole.UserRole)
self.model2.appendRow(items)
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 = []
raw_sort = self.sort_config
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
"""
debug('get_sort_config_by_index()')
debug("get_sort_config_by_index()")
sort_config = []
raw_sort = self.sort_config
if not raw_sort:
@ -1079,7 +1098,7 @@ class MusicTable(QTableView):
"""
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 in reverse order (primary sort last)
for col_index, order in reversed(sort_config):
@ -1090,11 +1109,12 @@ class MusicTable(QTableView):
else:
continue
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()
def get_audio_files_recursively(self, directories: list[str], progress_callback=None) -> list[str]:
def get_audio_files_recursively(
self, directories: list[str], progress_callback=None
) -> list[str]:
"""Scans a directories for files"""
extensions = self.config.get("settings", "extensions").split(",")
audio_files: list[str] = []
@ -1139,7 +1159,10 @@ class MusicTable(QTableView):
return []
selected_rows = set(index.row() for index in indexes)
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
@ -1163,9 +1186,13 @@ class MusicTable(QTableView):
except ValueError:
# if the user doesnt have filepath selected as a header, retrieve the file from db
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:
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
def set_current_song_filepath(self, filepath=None) -> None:
@ -1175,7 +1202,9 @@ class MusicTable(QTableView):
"""
# update the 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
else:
self.current_song_filepath = filepath
@ -1234,7 +1263,6 @@ class MusicTable(QTableView):
pass
# 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:

View File

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

118
main.py
View File

@ -7,8 +7,10 @@ import qdarktheme
from PyQt5.QtGui import QFontDatabase
from PyQt5 import QtCore
from subprocess import run
# from pyqtgraph import mkBrush
from mutagen.id3 import ID3
# from mutagen.id3._frames import APIC
from configparser import ConfigParser
from pathlib import Path
@ -49,7 +51,7 @@ from utils import (
set_album_art,
id3_remap,
get_album_art,
Worker
Worker,
)
from components import (
MediaPlayer,
@ -59,7 +61,7 @@ from components import (
CreatePlaylistWindow,
ExportPlaylistWindow,
HeaderTags2,
DebugWindow
DebugWindow,
)
from utils.export_playlist_by_id import export_playlist_by_id
@ -100,13 +102,17 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
# widget bits
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.player: QMediaPlayer = MediaPlayer()
# set index on choose song
# index is the model2's row number? i guess?
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
# Button styles
@ -147,10 +153,14 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.albumGraphicsView.setFixedSize(250, 250)
# 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.speedSlider.sliderMoved.connect(lambda: self.on_speed_changed(self.speedSlider.value()))
#self.speedSlider.doubleClicked.connect(lambda: self.on_speed_changed(1))
self.speedSlider.sliderMoved.connect(
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.previousButton.clicked.connect(self.on_prev_clicked)
self.nextButton.clicked.connect(self.on_next_clicked) # Click to next song
@ -165,7 +175,6 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.actionFontListing.triggered.connect(self.open_font_listing)
QFontDatabase().families()
# QUICK ACTIONS MENU
self.actionScanLibraries.triggered.connect(self.scan_libraries)
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)
# tableView
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.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.player.playlistNextSignal.connect(self.on_next_clicked)
# 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)
# albumGraphicsView
self.albumGraphicsView.albumArtDropped.connect(self.set_album_art_for_selected_songs)
self.albumGraphicsView.albumArtDeleted.connect(self.delete_album_art_for_current_song)
self.albumGraphicsView.albumArtDropped.connect(
self.set_album_art_for_selected_songs
)
self.albumGraphicsView.albumArtDeleted.connect(
self.delete_album_art_for_current_song
)
def nothing(self):
pass
@ -216,8 +234,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def closeEvent(self, a0: QCloseEvent | None) -> None:
"""Save settings when closing the application"""
self.config["settings"]["volume"] = str(self.current_volume)
self.config["settings"]["window_size"] = (str(self.width()) + "," + str(self.height()))
self.config['table']['column_ratios'] = ",".join(self.tableView.get_current_header_width_ratios())
self.config["settings"]["window_size"] = (
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
# Save the config
try:
@ -225,10 +247,13 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.config.write(configfile)
except Exception as e:
debug(f"wtf man {e}")
debug("Done writing config")
# auto export any playlists that want it
try:
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]
for id in ids:
export_playlist_by_id(id)
@ -298,8 +323,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
return
row: int = index.row()
prev_row: int = row - 1
prev_index: QModelIndex = self.tableView.proxymodel.index(prev_row, index.column())
prev_filepath = prev_index.siblingAtColumn(self.headers.db_list.index("filepath")).data()
prev_index: QModelIndex = self.tableView.proxymodel.index(
prev_row, index.column()
)
prev_filepath = prev_index.siblingAtColumn(
self.headers.db_list.index("filepath")
).data()
if prev_filepath is None:
return
@ -318,8 +347,12 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
return
row: int = index.row()
next_row: int = row + 1
next_index: QModelIndex = self.tableView.proxymodel.index(next_row, index.column())
next_filepath = next_index.siblingAtColumn(self.headers.db_list.index("filepath")).data()
next_index: QModelIndex = self.tableView.proxymodel.index(
next_row, index.column()
)
next_filepath = next_index.siblingAtColumn(
self.headers.db_list.index("filepath")
).data()
if next_filepath is None:
return
self.play_audio_file(next_filepath)
@ -368,8 +401,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def load_config(self) -> None:
"""does what it says"""
cfg_file = (
Path(user_config_dir(appname="musicpom",
appauthor="billypom")) / "config.ini"
Path(user_config_dir(appname="musicpom", appauthor="billypom"))
/ "config.ini"
)
self.config.read(cfg_file)
debug("load_config()")
@ -447,7 +480,8 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
for song in selected_songs:
debug(
f"main.py set_album_art_for_selected_songs() | updating album art for {
song}"
song
}"
)
set_album_art(song, album_art_path)
@ -474,8 +508,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
self.playbackSlider.setMaximum(self.player.duration())
slider_position = self.player.position()
self.playbackSlider.setValue(slider_position)
current_minutes, current_seconds = divmod(
slider_position / 1000, 60)
current_minutes, current_seconds = divmod(slider_position / 1000, 60)
duration_minutes, duration_seconds = divmod(
self.player.duration() / 1000, 60
)
@ -494,6 +527,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
"""
updates the status bar when progress is emitted
"""
# breakpoint()
self.show_status_bar_message(data)
# ____________________
@ -526,8 +560,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
def create_playlist(self) -> None:
"""Creates a database record for a playlist, given a name"""
window = CreatePlaylistWindow(self.playlistCreatedSignal)
window.playlistCreatedSignal.connect(
self.add_latest_playlist_to_tree) # type: ignore
window.playlistCreatedSignal.connect(self.add_latest_playlist_to_tree) # type: ignore
window.exec_()
def import_playlist(self) -> None:
@ -592,22 +625,21 @@ def update_database_file() -> bool:
Reads the database file (specified by config file)
"""
cfg_file = (
Path(user_config_dir(appname="musicpom",
appauthor="billypom")) / "config.ini"
Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini"
)
cfg_path = str(Path(user_config_dir(
appname="musicpom", appauthor="billypom")))
cfg_path = str(Path(user_config_dir(appname="musicpom", appauthor="billypom")))
config = ConfigParser()
config.read(cfg_file)
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 not db_filepath.startswith(cfg_path):
new_path = f"{cfg_path}/{db_filepath}"
debug(
f"Set new config [db] database path: \n> Current: {
db_filepath}\n> New:{new_path}"
f"Set new config [db] database path: \n> Current: {db_filepath}\n> New:{
new_path
}"
)
config["settings"]["db"] = new_path
# 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
"""
cfg_file = (
Path(user_config_dir(appname="musicpom",
appauthor="billypom")) / "config.ini"
Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini"
)
cfg_path = str(Path(user_config_dir(
appname="musicpom", appauthor="billypom")))
cfg_path = str(Path(user_config_dir(appname="musicpom", appauthor="billypom")))
# If config path doesn't exist, create it
if not os.path.exists(cfg_path):
@ -693,11 +723,11 @@ if __name__ == "__main__":
format="{%(filename)s:%(lineno)d} %(levelname)s - %(message)s",
handlers=handlers,
)
debug('--------- musicpom debug started')
debug('--------------------------------')
debug("--------- musicpom debug started")
debug("--------------------------------")
debug(handlers)
debug('--------------------------------')
debug('--------------------------------')
debug("--------------------------------")
debug("--------------------------------")
# Initialization
config: ConfigParser = update_config_file()
if not update_database_file():
@ -713,7 +743,7 @@ if __name__ == "__main__":
try:
# Dark theme >:3
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:
pass
# Show the UI

View File

@ -4,7 +4,7 @@ from .initialize_db import initialize_db
from .safe_get import safe_get
from .get_album_art import get_album_art
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 .delete_song_id_from_database import delete_song_id_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
from PyQt5.QtCore import QThreadPool
import DBA
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:
"""
Exports a playlist to its defined auto_export_path, by database ID
"""
logging.debug(f"Exporting playlist id: {playlist_db_id}")
threadpool = QThreadPool()
try:
with DBA.DBAccess() as db:
result = db.query('''
result = db.query(
"""
SELECT auto_export_path, path_prefix FROM playlist WHERE id = ?
''', (playlist_db_id,)
""",
(playlist_db_id,),
)
auto_export_path = result[0][0]
path_prefix = result[0][1]
except Exception as e:
logging.error(
f"export_playlist_by_id.py | Could not contact database: {e}"
)
logging.error(f"export_playlist_by_id.py | Could not contact database: {e}")
return False
# 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 = ?;""",
(playlist_db_id,),
)
db_paths = [path[0] for path in data]
db_paths = [Path(path[0]) for path in data]
except Exception as e:
logging.error(
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
write_paths = []
# Relative paths
logging.debug("Creating relative paths...")
for song in db_paths:
artist, album = get_reorganize_vars(song)
write_path = os.path.join(
path_prefix, artist, album, song.split("/")[-1] + "\n"
)
write_paths.append(str(write_path))
artist, album = parse_artist_album(song)
write_path = Path(path_prefix) / artist / album / song.name
write_paths.append(str(write_path) + "\n")
worker = Worker(write_to_playlist_file, write_paths, auto_export_path)
# worker.signals.signal_finished.connect(None)
@ -64,10 +67,15 @@ def export_playlist_by_id(playlist_db_id: int) -> bool:
threadpool.start(worker)
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
"""
os.makedirs(os.path.dirname(outfile), exist_ok=True)
logging.debug("Writing paths to m3u file")
with open(outfile, "w") as f:
f.writelines(paths)
logging.debug("Done writing m3u files")

View File

@ -1,7 +1,8 @@
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
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,
function will return ("Unknown Artist", "Unknown Album")
"""
# logging.debug(f"getting reorganize vars for {filepath}")
# TODO: fix this func. id3_remap(get_tags())
# or is what i have less memory so more better? :shrug:
audio = ID3(filepath)
@ -27,3 +29,78 @@ def get_reorganize_vars(filepath: str) -> tuple[str, str]:
album = "Unknown 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