Rebase dev onto v2_id

This commit is contained in:
Keith Edmunds 2022-03-02 09:16:07 +00:00
parent b92a0927f8
commit cf58932fca
10 changed files with 461 additions and 239 deletions

View File

@ -3,6 +3,7 @@ import os
class Config(object): class Config(object):
AUDACITY_COMMAND = "/usr/bin/audacity"
AUDIO_SEGMENT_CHUNK_SIZE = 10 AUDIO_SEGMENT_CHUNK_SIZE = 10
COLOUR_CURRENT_HEADER = "#d4edda" COLOUR_CURRENT_HEADER = "#d4edda"
COLOUR_CURRENT_PLAYLIST = "#7eca8f" COLOUR_CURRENT_PLAYLIST = "#7eca8f"
@ -19,8 +20,17 @@ class Config(object):
COLOUR_PREVIOUS_HEADER = "#f8d7da" COLOUR_PREVIOUS_HEADER = "#f8d7da"
COLOUR_UNREADABLE = "#dc3545" COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap"
COLUMN_NAME_LENGTH = "Length"
COLUMN_NAME_START_TIME = "Start"
COLUMN_NAME_TITLE = "Title"
DBFS_FADE = -12 DBFS_FADE = -12
DBFS_SILENCE = -50 DBFS_SILENCE = -50
DEFAULT_COLUMN_WIDTH = 200
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp" DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles" DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
DISPLAY_SQL = False DISPLAY_SQL = False

View File

@ -1,7 +1,7 @@
import os import os
import psutil import psutil
from app.config import Config from config import Config
from datetime import datetime from datetime import datetime
from pydub import AudioSegment from pydub import AudioSegment
from mutagen.flac import FLAC from mutagen.flac import FLAC
@ -10,6 +10,14 @@ from PyQt5.QtWidgets import QMessageBox
from tinytag import TinyTag from tinytag import TinyTag
def ask_yes_no(title, question):
"""Ask question; return True for yes, False for no"""
buttonResponse = QMessageBox.question(
self, title, question, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No)
def fade_point(audio_segment, fade_threshold=0, def fade_point(audio_segment, fade_threshold=0,
chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE): chunk_size=Config.AUDIO_SEGMENT_CHUNK_SIZE):
""" """

View File

@ -25,39 +25,33 @@ from sqlalchemy.orm import backref, relationship, sessionmaker, scoped_session
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
from app.config import Config from config import Config
from app.helpers import ( from helpers import (
fade_point, fade_point,
get_audio_segment, get_audio_segment,
leading_silence, leading_silence,
show_warning, show_warning,
trailing_silence, trailing_silence,
) )
from app.log import DEBUG, ERROR from log import DEBUG, ERROR
# Create session at the global level as per # Create session at the global level as per
# https://docs.sqlalchemy.org/en/13/orm/session_basics.html # https://docs.sqlalchemy.org/en/13/orm/session_basics.html
Base = declarative_base() engine = sqlalchemy.create_engine(
Session = scoped_session(sessionmaker()) f"{Config.MYSQL_CONNECT}?charset=utf8",
encoding='utf-8',
echo=Config.DISPLAY_SQL,
pool_pre_ping=True)
Session = scoped_session(sessionmaker(bind=engine))
Base: DeclarativeMeta = declarative_base()
Base.metadata.create_all(engine)
def db_init(): def db_init():
# Set up database connection return
global Session
engine = sqlalchemy.create_engine(
f"{Config.MYSQL_CONNECT}?charset=utf8",
encoding='utf-8',
echo=Config.DISPLAY_SQL,
pool_pre_ping=True)
Session.configure(bind=engine)
Base.metadata.create_all(engine)
# Create a Session factory
Session = sessionmaker(bind=engine)
# Database classes # Database classes

View File

@ -10,7 +10,7 @@ import urllib.parse
from datetime import datetime, timedelta from datetime import datetime, timedelta
from log import DEBUG, EXCEPTION from log import DEBUG, EXCEPTION
from PyQt5.QtCore import Qt, QTimer, QUrl from PyQt5.QtCore import QProcess, Qt, QTimer, QUrl
from PyQt5.QtGui import QColor, QFontMetrics, QPainter from PyQt5.QtGui import QColor, QFontMetrics, QPainter
from PyQt5.QtWebEngineWidgets import QWebEngineView as QWebView from PyQt5.QtWebEngineWidgets import QWebEngineView as QWebView
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@ -43,16 +43,19 @@ class ElideLabel(QLabel):
""" """
def paintEvent(self, event): def paintEvent(self, event):
#TODO: V2 check
painter = QPainter(self) painter = QPainter(self)
metrics = QFontMetrics(self.font()) metrics = QFontMetrics(self.font())
elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width()) elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
painter.drawText(self.rect(), self.alignment(), elided) painter.drawText(self.rect(), self.alignment(), elided)
#TODO: V2 check
class Window(QMainWindow, Ui_MainWindow): class Window(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None): def __init__(self, parent=None):
#TODO: V2 check
super().__init__(parent) super().__init__(parent)
self.setupUi(self) self.setupUi(self)
@ -85,6 +88,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer.start(Config.TIMER_MS) self.timer.start(Config.TIMER_MS)
def add_file(self): def add_file(self):
#TODO: V2 check
dlg = QFileDialog() dlg = QFileDialog()
dlg.setFileMode(QFileDialog.ExistingFiles) dlg.setFileMode(QFileDialog.ExistingFiles)
dlg.setViewMode(QFileDialog.Detail) dlg.setViewMode(QFileDialog.Detail)
@ -98,9 +102,10 @@ class Window(QMainWindow, Ui_MainWindow):
# Add to playlist on screen # Add to playlist on screen
# If we don't specify "repaint=False", playlist will # If we don't specify "repaint=False", playlist will
# also be saved to database # also be saved to database
self.visible_playlist_tab().insert_track(session, track) self.visible_playlist_tab()._insert_track(session, track)
def set_main_window_size(self): def set_main_window_size(self):
#TODO: V2 check
"Set size of window from database" "Set size of window from database"
with Session() as session: with Session() as session:
@ -115,18 +120,22 @@ class Window(QMainWindow, Ui_MainWindow):
self.setGeometry(x, y, width, height) self.setGeometry(x, y, width, height)
def check_audacity(self): def check_audacity(self):
"Warn user if Audacity not running" #TODO: V2 check
"Offer to run Audacity if not running"
if "audacity" in [i.name() for i in psutil.process_iter()]: if "audacity" in [i.name() for i in psutil.process_iter()]:
return return
helpers.show_warning("Audacity check", "Audacity is not running") if helpers.ask_yes_no("Audacity not running", "Start Audacity?"):
QProcess.startDetached(Config.AUDACITY_COMMAND, [])
def clear_selection(self): def clear_selection(self):
#TODO: V2 check
if self.visible_playlist_tab(): if self.visible_playlist_tab():
self.visible_playlist_tab().clearSelection() self.visible_playlist_tab().clearSelection()
def closeEvent(self, event): def closeEvent(self, event):
#TODO: V2 check
"Don't allow window to close when a track is playing" "Don't allow window to close when a track is playing"
if self.music.playing(): if self.music.playing():
@ -165,6 +174,7 @@ class Window(QMainWindow, Ui_MainWindow):
event.accept() event.accept()
def connect_signals_slots(self): def connect_signals_slots(self):
#TODO: V2 check
self.actionAdd_file.triggered.connect(self.add_file) self.actionAdd_file.triggered.connect(self.add_file)
self.actionAdd_note.triggered.connect(self.insert_note) self.actionAdd_note.triggered.connect(self.insert_note)
self.action_Clear_selection.triggered.connect(self.clear_selection) self.action_Clear_selection.triggered.connect(self.clear_selection)
@ -202,6 +212,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer.timeout.connect(self.tick) self.timer.timeout.connect(self.tick)
def create_playlist(self): def create_playlist(self):
#TODO: V2 check
"Create new playlist" "Create new playlist"
dlg = QInputDialog(self) dlg = QInputDialog(self)
@ -212,9 +223,10 @@ class Window(QMainWindow, Ui_MainWindow):
if ok: if ok:
with Session() as session: with Session() as session:
playlist_db = Playlists(session, dlg.textValue()) playlist_db = Playlists(session, dlg.textValue())
self.load_playlist(session, playlist_db) self.create_playlist_tab(session, playlist_db)
def change_volume(self, volume): def change_volume(self, volume):
#TODO: V2 check
"Change player maximum volume" "Change player maximum volume"
DEBUG(f"change_volume({volume})") DEBUG(f"change_volume({volume})")
@ -222,9 +234,11 @@ class Window(QMainWindow, Ui_MainWindow):
self.music.set_volume(volume) self.music.set_volume(volume)
def close_playlist_tab(self): def close_playlist_tab(self):
#TODO: V2 check
self.close_tab(self.tabPlaylist.currentIndex()) self.close_tab(self.tabPlaylist.currentIndex())
def close_tab(self, index): def close_tab(self, index):
#TODO: V2 check
if hasattr(self.tabPlaylist.widget(index), 'is_playlist'): if hasattr(self.tabPlaylist.widget(index), 'is_playlist'):
if self.tabPlaylist.widget(index) == ( if self.tabPlaylist.widget(index) == (
self.current_track_playlist_tab): self.current_track_playlist_tab):
@ -244,6 +258,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.tabPlaylist.removeTab(index) self.tabPlaylist.removeTab(index)
def create_note(self, session, text): def create_note(self, session, text):
#TODO: V2 check
""" """
Create note Create note
@ -265,16 +280,19 @@ class Window(QMainWindow, Ui_MainWindow):
return note return note
def disable_play_next_controls(self): def disable_play_next_controls(self):
#TODO: V2 check
DEBUG("disable_play_next_controls()") DEBUG("disable_play_next_controls()")
self.actionPlay_next.setEnabled(False) self.actionPlay_next.setEnabled(False)
self.statusbar.showMessage("Play controls: Disabled", 0) self.statusbar.showMessage("Play controls: Disabled", 0)
def enable_play_next_controls(self): def enable_play_next_controls(self):
#TODO: V2 check
DEBUG("enable_play_next_controls()") DEBUG("enable_play_next_controls()")
self.actionPlay_next.setEnabled(True) self.actionPlay_next.setEnabled(True)
self.statusbar.showMessage("Play controls: Enabled", 0) self.statusbar.showMessage("Play controls: Enabled", 0)
def end_of_track_actions(self): def end_of_track_actions(self):
#TODO: V2 check
"Clean up after track played" "Clean up after track played"
# Set self.playing to False so that tick() doesn't see # Set self.playing to False so that tick() doesn't see
@ -301,6 +319,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.enable_play_next_controls() self.enable_play_next_controls()
def ensure_info_tabs(self, title_list): def ensure_info_tabs(self, title_list):
#TODO: V2 check
""" """
Ensure we have info tabs for each of the passed titles Ensure we have info tabs for each of the passed titles
""" """
@ -336,6 +355,7 @@ class Window(QMainWindow, Ui_MainWindow):
widget.setUrl(QUrl(url)) widget.setUrl(QUrl(url))
def export_playlist_tab(self): def export_playlist_tab(self):
#TODO: V2 check
"Export the current playlist to an m3u file" "Export the current playlist to an m3u file"
if not self.visible_playlist_tab(): if not self.visible_playlist_tab():
@ -374,6 +394,7 @@ class Window(QMainWindow, Ui_MainWindow):
) )
def fade(self): def fade(self):
#TODO: V2 check
"Fade currently playing track" "Fade currently playing track"
DEBUG("musicmuster:fade()", True) DEBUG("musicmuster:fade()", True)
@ -385,6 +406,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.end_of_track_actions() self.end_of_track_actions()
def insert_note(self): def insert_note(self):
#TODO: V2 check
"Add non-track row to playlist" "Add non-track row to playlist"
dlg = QInputDialog(self) dlg = QInputDialog(self)
@ -395,28 +417,29 @@ class Window(QMainWindow, Ui_MainWindow):
if ok: if ok:
with Session() as session: with Session() as session:
note = self.create_note(session, dlg.textValue()) note = self.create_note(session, dlg.textValue())
self.visible_playlist_tab().insert_note(session, note) self.visible_playlist_tab()._insert_note(session, note)
def load_last_playlists(self): def load_last_playlists(self):
#TODO: V2 check
"Load the playlists that we loaded at end of last session" "Load the playlists that we loaded at end of last session"
with Session() as session: with Session() as session:
for playlist_db in Playlists.get_open(session): for playlist_db in Playlists.get_open(session):
self.load_playlist(session, playlist_db) self.create_playlist_tab(session, playlist_db)
def load_playlist(self, session, playlist_db): def create_playlist_tab(self, session, playlist_db):
#TODO: V2 check
""" """
Take the passed database object, create a playlist display, attach Take the passed database object, create a playlist tab and add tab
the database object, get it populated and then add tab. to display.
""" """
playlist_tab = PlaylistTab(self) playlist_tab = PlaylistTab(self, session, playlist_db)
playlist_db.mark_open(session)
playlist_tab.populate(session, playlist_db)
idx = self.tabPlaylist.addTab(playlist_tab, playlist_db.name) idx = self.tabPlaylist.addTab(playlist_tab, playlist_db.name)
self.tabPlaylist.setCurrentIndex(idx) self.tabPlaylist.setCurrentIndex(idx)
def move_selected(self): def move_selected(self):
#TODO: V2 check
"Move selected rows to another playlist" "Move selected rows to another playlist"
# TODO needs refactoring # TODO needs refactoring
@ -465,6 +488,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.visible_playlist_tab().remove_rows(rows) self.visible_playlist_tab().remove_rows(rows)
def play_next(self): def play_next(self):
#TODO: V2 check
""" """
Play next track. Play next track.
@ -545,35 +569,41 @@ class Window(QMainWindow, Ui_MainWindow):
)) ))
def search_database(self): def search_database(self):
#TODO: V2 check
with Session() as session: with Session() as session:
dlg = DbDialog(self, session) dlg = DbDialog(self, session)
dlg.exec() dlg.exec()
def open_playlist(self): def open_playlist(self):
#TODO: V2 check
with Session() as session: with Session() as session:
playlist_dbs = Playlists.get_closed(session) playlist_dbs = Playlists.get_closed(session)
dlg = SelectPlaylistDialog(self, playlist_dbs=playlist_dbs) dlg = SelectPlaylistDialog(self, playlist_dbs=playlist_dbs)
dlg.exec() dlg.exec()
if dlg.plid: if dlg.plid:
playlist_db = Playlists.get_by_id(session, dlg.plid) playlist_db = Playlists.get_by_id(session, dlg.plid)
self.load_playlist(session, playlist_db) self.create_playlist_tab(session, playlist_db)
def select_next_row(self): def select_next_row(self):
#TODO: V2 check
"Select next or first row in playlist" "Select next or first row in playlist"
self.visible_playlist_tab().select_next_row() self.visible_playlist_tab().select_next_row()
def select_played(self): def select_played(self):
#TODO: V2 check
"Select all played tracks in playlist" "Select all played tracks in playlist"
self.visible_playlist_tab().select_played_tracks() self.visible_playlist_tab().select_played_tracks()
def select_previous_row(self): def select_previous_row(self):
#TODO: V2 check
"Select previous or first row in playlist" "Select previous or first row in playlist"
self.visible_playlist_tab().select_previous_row() self.visible_playlist_tab().select_previous_row()
def set_next_track(self, next_track_id=None): def set_next_track(self, next_track_id=None):
#TODO: V2 check
"Set selected track as next" "Set selected track as next"
with Session() as session: with Session() as session:
@ -609,11 +639,13 @@ class Window(QMainWindow, Ui_MainWindow):
self.update_headers() self.update_headers()
def select_unplayed(self): def select_unplayed(self):
#TODO: V2 check
"Select all unplayed tracks in playlist" "Select all unplayed tracks in playlist"
self.visible_playlist_tab().select_unplayed_tracks() self.visible_playlist_tab().select_unplayed_tracks()
def set_tab_colour(self, widget, colour): def set_tab_colour(self, widget, colour):
#TODO: V2 check
""" """
Find the tab containing the widget and set the text colour Find the tab containing the widget and set the text colour
""" """
@ -622,6 +654,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.tabPlaylist.tabBar().setTabTextColor(idx, colour) self.tabPlaylist.tabBar().setTabTextColor(idx, colour)
def song_info_search(self): def song_info_search(self):
#TODO: V2 check
""" """
Open browser tabs for Wikipedia, searching for Open browser tabs for Wikipedia, searching for
the first that exists of: the first that exists of:
@ -644,6 +677,7 @@ class Window(QMainWindow, Ui_MainWindow):
webbrowser.open(url, new=2) webbrowser.open(url, new=2)
def stop(self): def stop(self):
#TODO: V2 check
"Stop playing immediately" "Stop playing immediately"
DEBUG("musicmuster.stop()") DEBUG("musicmuster.stop()")
@ -651,6 +685,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.stop_playing(fade=False) self.stop_playing(fade=False)
def stop_playing(self, fade=True): def stop_playing(self, fade=True):
#TODO: V2 check
"Stop playing current track" "Stop playing current track"
DEBUG(f"musicmuster.stop_playing({fade=})", True) DEBUG(f"musicmuster.stop_playing({fade=})", True)
@ -682,11 +717,13 @@ class Window(QMainWindow, Ui_MainWindow):
self.update_headers() self.update_headers()
def test_function(self): def test_function(self):
#TODO: V2 check
"Placeholder for test function" "Placeholder for test function"
pass pass
def test_skip_to_end(self): def test_skip_to_end(self):
#TODO: V2 check
"Skip current track to 1 second before silence" "Skip current track to 1 second before silence"
if not self.playing: if not self.playing:
@ -695,6 +732,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.music.set_position(self.current_track.silence_at - 1000) self.music.set_position(self.current_track.silence_at - 1000)
def test_skip_to_fade(self): def test_skip_to_fade(self):
#TODO: V2 check
"Skip current track to 1 second before fade" "Skip current track to 1 second before fade"
if not self.music.playing(): if not self.music.playing():
@ -703,6 +741,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.music.set_position(self.current_track.fade_at - 1000) self.music.set_position(self.current_track.fade_at - 1000)
def tick(self): def tick(self):
#TODO: V2 check
""" """
Update screen Update screen
@ -775,6 +814,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.stop_playing() self.stop_playing()
def update_headers(self): def update_headers(self):
#TODO: V2 check
""" """
Update last / current / next track headers. Update last / current / next track headers.
Ensure a Wikipedia tab for each title. Ensure a Wikipedia tab for each title.
@ -811,6 +851,7 @@ class Window(QMainWindow, Ui_MainWindow):
class DbDialog(QDialog): class DbDialog(QDialog):
def __init__(self, parent, session): def __init__(self, parent, session):
#TODO: V2 check
super().__init__(parent) super().__init__(parent)
self.session = session self.session = session
self.ui = Ui_Dialog() self.ui = Ui_Dialog()
@ -830,6 +871,7 @@ class DbDialog(QDialog):
self.resize(width, height) self.resize(width, height)
def __del__(self): def __del__(self):
#TODO: V2 check
record = Settings.get_int(self.session, "dbdialog_height") record = Settings.get_int(self.session, "dbdialog_height")
if record.f_int != self.height(): if record.f_int != self.height():
record.update(self.session, {'f_int': self.height()}) record.update(self.session, {'f_int': self.height()})
@ -839,6 +881,7 @@ class DbDialog(QDialog):
record.update(self.session, {'f_int': self.width()}) record.update(self.session, {'f_int': self.width()})
def add_selected(self): def add_selected(self):
#TODO: V2 check
if not self.ui.matchList.selectedItems(): if not self.ui.matchList.selectedItems():
return return
@ -847,10 +890,12 @@ class DbDialog(QDialog):
self.add_track(track_id) self.add_track(track_id)
def add_selected_and_close(self): def add_selected_and_close(self):
#TODO: V2 check
self.add_selected() self.add_selected()
self.close() self.close()
def radio_toggle(self): def radio_toggle(self):
#TODO: V2 check
""" """
Handle switching between searching for artists and searching for Handle switching between searching for artists and searching for
titles titles
@ -860,6 +905,7 @@ class DbDialog(QDialog):
self.chars_typed(self.ui.searchString.text()) self.chars_typed(self.ui.searchString.text())
def chars_typed(self, s): def chars_typed(self, s):
#TODO: V2 check
if len(s) > 0: if len(s) > 0:
if self.ui.radioTitle.isChecked(): if self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, s) matches = Tracks.search_titles(self.session, s)
@ -877,36 +923,42 @@ class DbDialog(QDialog):
self.ui.matchList.addItem(t) self.ui.matchList.addItem(t)
def double_click(self, entry): def double_click(self, entry):
#TODO: V2 check
track_id = entry.data(Qt.UserRole) track_id = entry.data(Qt.UserRole)
self.add_track(track_id) self.add_track(track_id)
# Select search text to make it easier for next search # Select search text to make it easier for next search
self.select_searchtext() self.select_searchtext()
def add_track(self, track_id): def add_track(self, track_id):
#TODO: V2 check
track = Tracks.get_by_id(self.session, track_id) track = Tracks.get_by_id(self.session, track_id)
# Add to playlist on screen # Add to playlist on screen
# If we don't specify "repaint=False", playlist will # If we don't specify "repaint=False", playlist will
# also be saved to database # also be saved to database
self.parent().visible_playlist_tab().insert_track( self.parent().visible_playlist_tab()._insert_track(
self.session, track) self.session, track)
# Select search text to make it easier for next search # Select search text to make it easier for next search
self.select_searchtext() self.select_searchtext()
def select_searchtext(self): def select_searchtext(self):
#TODO: V2 check
self.ui.searchString.selectAll() self.ui.searchString.selectAll()
self.ui.searchString.setFocus() self.ui.searchString.setFocus()
def selection_changed(self): def selection_changed(self):
#TODO: V2 check
if not self.ui.matchList.selectedItems(): if not self.ui.matchList.selectedItems():
return return
item = self.ui.matchList.currentItem() item = self.ui.matchList.currentItem()
track_id = item.data(Qt.UserRole) track_id = item.data(Qt.UserRole)
self.ui.dbPath.setText(Tracks.get_path(self.session, track_id)) self.ui.dbPath.setText(Tracks.get_path(self.session, track_id))
#TODO: V2 check
class SelectPlaylistDialog(QDialog): class SelectPlaylistDialog(QDialog):
def __init__(self, parent=None, playlist_dbs=None): def __init__(self, parent=None, playlist_dbs=None):
#TODO: V2 check
super().__init__(parent) super().__init__(parent)
if playlist_dbs is None: if playlist_dbs is None:
@ -932,6 +984,7 @@ class SelectPlaylistDialog(QDialog):
self.ui.lstPlaylists.addItem(p) self.ui.lstPlaylists.addItem(p)
def __del__(self): def __del__(self):
#TODO: V2 check
with Session() as session: with Session() as session:
record = Settings.get_int(session, "select_playlist_dialog_height") record = Settings.get_int(session, "select_playlist_dialog_height")
if record.f_int != self.height(): if record.f_int != self.height():
@ -942,14 +995,17 @@ class SelectPlaylistDialog(QDialog):
record.update(session, {'f_int': self.width()}) record.update(session, {'f_int': self.width()})
def list_doubleclick(self, entry): def list_doubleclick(self, entry):
#TODO: V2 check
self.plid = entry.data(Qt.UserRole) self.plid = entry.data(Qt.UserRole)
self.accept() self.accept()
def open(self): def open(self):
#TODO: V2 check
if self.ui.lstPlaylists.selectedItems(): if self.ui.lstPlaylists.selectedItems():
item = self.ui.lstPlaylists.currentItem() item = self.ui.lstPlaylists.currentItem()
self.plid = item.data(Qt.UserRole) self.plid = item.data(Qt.UserRole)
self.accept() self.accept()
#TODO: V2 check
def main(): def main():
@ -963,5 +1019,6 @@ def main():
EXCEPTION("Unhandled Exception caught by musicmuster.main()") EXCEPTION("Unhandled Exception caught by musicmuster.main()")
print(f"{__name__=}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -11,6 +11,7 @@ from PyQt5.QtWidgets import (
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
) )
from sqlalchemy import inspect
import helpers import helpers
import os import os
@ -28,7 +29,7 @@ from models import (
Tracks, Tracks,
NoteColours NoteColours
) )
from utilities import create_track_from_file, update_meta from utilities import create_track_from_file
class PlaylistTab(QTableWidget): class PlaylistTab(QTableWidget):
@ -37,7 +38,7 @@ class PlaylistTab(QTableWidget):
cellEditingEnded = QtCore.pyqtSignal() cellEditingEnded = QtCore.pyqtSignal()
# Column names # Column names
COL_INDEX = 0 COL_AUTOPLAY = COL_USERDATA = 0
COL_MSS = 1 COL_MSS = 1
COL_NOTE = 2 COL_NOTE = 2
COL_TITLE = 2 COL_TITLE = 2
@ -45,19 +46,23 @@ class PlaylistTab(QTableWidget):
COL_DURATION = 4 COL_DURATION = 4
COL_START_TIME = 5 COL_START_TIME = 5
COL_END_TIME = 6 COL_END_TIME = 6
COL_LAST_PLAYED = 7 COL_LAST_PLAYED = COL_LAST = 7
COL_LAST = 7
NOTE_COL_SPAN = COL_LAST - COL_NOTE + 1 NOTE_COL_SPAN = COL_LAST - COL_NOTE + 1
NOTE_ROW_SPAN = 1 NOTE_ROW_SPAN = 1
def __init__(self, *args, **kwargs): # Qt.UserRoles
ROW_METADATA = Qt.UserRole
CONTENT_OBJECT = Qt.UserRole + 1
def __init__(self, parent, session, playlist_db, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.id = None self.master_process = self.parent() # The MusicMuster process
self.name = None self.playlist = playlist_db
self.is_playlist = True self.playlist.mark_open(session)
self.master_process = self.parent()
# Set up widget
self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
@ -65,6 +70,7 @@ class PlaylistTab(QTableWidget):
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.setRowCount(0) self.setRowCount(0)
self.setColumnCount(8) self.setColumnCount(8)
# Add header row
item = QtWidgets.QTableWidgetItem() item = QtWidgets.QTableWidgetItem()
self.setHorizontalHeaderItem(0, item) self.setHorizontalHeaderItem(0, item)
item = QtWidgets.QTableWidgetItem() item = QtWidgets.QTableWidgetItem()
@ -84,8 +90,16 @@ class PlaylistTab(QTableWidget):
self.horizontalHeader().setMinimumSectionSize(0) self.horizontalHeader().setMinimumSectionSize(0)
self._set_column_widths() self._set_column_widths()
self.setHorizontalHeaderLabels(["ID", "Lead", "Title", "Artist", self.setHorizontalHeaderLabels([
"Len", "Start", "End", "Last played"]) Config.COLUMN_NAME_AUTOPLAY,
Config.COLUMN_NAME_LEADING_SILENCE,
Config.COLUMN_NAME_TITLE,
Config.COLUMN_NAME_ARTIST,
Config.COLUMN_NAME_LENGTH,
Config.COLUMN_NAME_START_TIME,
Config.COLUMN_NAME_END_TIME,
Config.COLUMN_NAME_LAST_PLAYED,
])
self.setDragEnabled(True) self.setDragEnabled(True)
self.setAcceptDrops(True) self.setAcceptDrops(True)
@ -97,7 +111,7 @@ class PlaylistTab(QTableWidget):
self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setDragDropMode(QAbstractItemView.InternalMove) self.setDragDropMode(QAbstractItemView.InternalMove)
# This property holds how the widget shows a context menu # This property defines how the widget shows a context menu
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
# This signal is emitted when the widget's contextMenuPolicy is # This signal is emitted when the widget's contextMenuPolicy is
# Qt::CustomContextMenu, and the user has requested a context # Qt::CustomContextMenu, and the user has requested a context
@ -113,12 +127,14 @@ class PlaylistTab(QTableWidget):
self.cellEditingStarted.connect(self._cell_edit_started) self.cellEditingStarted.connect(self._cell_edit_started)
self.cellEditingEnded.connect(self._cell_edit_ended) self.cellEditingEnded.connect(self._cell_edit_ended)
self.populate(session)
self.current_track_start_time = None self.current_track_start_time = None
self.played_tracks = [] self.played_tracks = []
# ########## Events ########## # ########## Events ##########
def dropEvent(self, event: QDropEvent): def dropEvent(self, event: QDropEvent):
# TODO: V2 check
if not event.isAccepted() and event.source() == self: if not event.isAccepted() and event.source() == self:
drop_row = self._drop_on(event) drop_row = self._drop_on(event)
@ -165,16 +181,19 @@ class PlaylistTab(QTableWidget):
self.update_display() self.update_display()
def edit(self, index, trigger, event): def edit(self, index, trigger, event):
# TODO: V2 check
result = super(PlaylistTab, self).edit(index, trigger, event) result = super(PlaylistTab, self).edit(index, trigger, event)
if result: if result:
self.cellEditingStarted.emit(index.row(), index.column()) self.cellEditingStarted.emit(index.row(), index.column())
return result return result
def closeEditor(self, editor, hint): def closeEditor(self, editor, hint):
# TODO: V2 check
super(PlaylistTab, self).closeEditor(editor, hint) super(PlaylistTab, self).closeEditor(editor, hint)
self.cellEditingEnded.emit() self.cellEditingEnded.emit()
def eventFilter(self, source, event): def eventFilter(self, source, event):
# TODO: V2 check
"Used to process context (right-click) menu" "Used to process context (right-click) menu"
if(event.type() == QtCore.QEvent.MouseButtonPress and # noqa W504 if(event.type() == QtCore.QEvent.MouseButtonPress and # noqa W504
@ -215,6 +234,7 @@ class PlaylistTab(QTableWidget):
# ########## Externally called functions ########## # ########## Externally called functions ##########
def close(self, session): def close(self, session):
# TODO: V2 check
"Save column widths" "Save column widths"
for column in range(self.columnCount()): for column in range(self.columnCount()):
@ -224,136 +244,22 @@ class PlaylistTab(QTableWidget):
if record.f_int != self.columnWidth(column): if record.f_int != self.columnWidth(column):
record.update(session, {'f_int': width}) record.update(session, {'f_int': width})
def insert_note(self, session, note, repaint=True):
"""
Add note to playlist
If a row is selected, add note above. Otherwise, add to end of
playlist.
Return the row number that track is now in.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(f"playlist.inset_note(): row={row}")
# Does note end with a time?
start_time = None
try:
start_time = datetime.strptime(note.note[-9:], " %H:%M:%S").time()
DEBUG(
f"playlist.inset_note(): Note contains valid time={start_time}"
)
except ValueError:
DEBUG(
f"playlist.inset_note(): Note on row {row} ('{note.note}') "
"does not contain valid time"
)
self.insertRow(row)
item = QTableWidgetItem(str(note.id))
self.setItem(row, self.COL_INDEX, item)
titleitem = QTableWidgetItem(note.note)
self.setItem(row, self.COL_NOTE, titleitem)
self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN,
self.NOTE_COL_SPAN)
# Add start/end times or empty items as background
# colour won't be set for columns without items
self._set_row_start_time(row, start_time)
item = QTableWidgetItem()
self.setItem(row, self.COL_END_TIME, item)
item = QTableWidgetItem()
self.setItem(row, self.COL_LAST_PLAYED, item)
self._meta_set_note(row)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self.save_playlist(session)
self.update_display(clear_selection=False)
return row
def insert_track(self, session, track, repaint=True):
"""
Insert track into on-screen playlist.
If a row is selected, add track above. Otherwise, add to end of
playlist.
Return the row number that track is now in.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(
f"playlists.insert_track({session=}, {track=}, {repaint=}), "
f"{row=}"
)
self.insertRow(row)
item = QTableWidgetItem(str(track.id))
self.setItem(row, self.COL_INDEX, item)
item = QTableWidgetItem(str(track.start_gap))
if track.start_gap >= 500:
item.setBackground(QColor(Config.COLOUR_LONG_START))
self.setItem(row, self.COL_MSS, item)
titleitem = QTableWidgetItem(track.title)
self.setItem(row, self.COL_TITLE, titleitem)
item = QTableWidgetItem(track.artist)
self.setItem(row, self.COL_ARTIST, item)
item = QTableWidgetItem(helpers.ms_to_mmss(track.duration))
self.setItem(row, self.COL_DURATION, item)
last_playtime = Playdates.last_played(session, track.id)
last_played_str = get_relative_date(last_playtime)
item = QTableWidgetItem(last_played_str)
self.setItem(row, self.COL_LAST_PLAYED, item)
# Add empty start time for now as background
# colour won't be set for columns without items
item = QTableWidgetItem()
self.setItem(row, self.COL_START_TIME, item)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if not self._track_path_is_readable(track.id):
self._meta_set_unreadable(row)
if repaint:
self.save_playlist(session)
self.update_display(clear_selection=False)
return row
def clear_current(self): def clear_current(self):
# TODO: V2 check
"Clear current track" "Clear current track"
self._meta_clear_current() self._meta_clear_current()
self.update_display() self.update_display()
def clear_next(self): def clear_next(self):
# TODO: V2 check
"""Clear next track""" """Clear next track"""
self._meta_clear_next() self._meta_clear_next()
self.update_display() self.update_display()
def get_next_track_id(self):
"Return next track id"
next_row = self._meta_get_next()
return self._get_row_id(next_row)
def get_selected_row(self): def get_selected_row(self):
# TODO: V2 check
"Return row number of first selected row, or None if none selected" "Return row number of first selected row, or None if none selected"
if not self.selectionModel().hasSelection(): if not self.selectionModel().hasSelection():
@ -362,6 +268,7 @@ class PlaylistTab(QTableWidget):
return self.selectionModel().selectedRows()[0].row() return self.selectionModel().selectedRows()[0].row()
def get_selected_rows_and_tracks(self): def get_selected_rows_and_tracks(self):
# TODO: V2 check
"Return a list of selected (rows, track_id) tuples" "Return a list of selected (rows, track_id) tuples"
if not self.selectionModel().hasSelection(): if not self.selectionModel().hasSelection():
@ -376,6 +283,7 @@ class PlaylistTab(QTableWidget):
return result return result
def get_selected_title(self): def get_selected_title(self):
# TODO: V2 check
"Return title of selected row or None" "Return title of selected row or None"
if self.selectionModel().hasSelection(): if self.selectionModel().hasSelection():
@ -385,6 +293,7 @@ class PlaylistTab(QTableWidget):
return None return None
def remove_rows(self, rows): def remove_rows(self, rows):
# TODO: V2 check
"Remove rows passed in rows list" "Remove rows passed in rows list"
# Row number will change as we delete rows. We could use # Row number will change as we delete rows. We could use
@ -400,6 +309,7 @@ class PlaylistTab(QTableWidget):
self.update_display() self.update_display()
def play_started(self): def play_started(self):
# TODO: V2 check
""" """
Update current track to be what was next, and determine next track. Update current track to be what was next, and determine next track.
Return next track_id. Return next track_id.
@ -428,32 +338,26 @@ class PlaylistTab(QTableWidget):
return next_track_id return next_track_id
def play_stopped(self): def play_stopped(self):
# TODO: V2 check
self._meta_clear_current() self._meta_clear_current()
self.current_track_start_time = None self.current_track_start_time = None
self.update_display() self.update_display()
def populate(self, session, playlist_db): def populate(self, session):
""" """
Populate ourself from the passed playlist_db object Populate ourself from the associated playlist object
We don't mandate that an item will be on its specified row, only
that it will be above larger-numbered row items, and below
lower-numbered ones.
""" """
# We don't mandate that an item will be
# on its specified row, only that it will be above
# larger-numbered row items, and below lower-numbered ones.
# That means we need to re-save ourself once loaded to ensure
# database is correct.
# First, save our id for the future
self.id = playlist_db.id
self.name = playlist_db.name
data = [] data = []
for t in playlist_db.tracks: for row, track in self.playlist.tracks.items():
data.append(([t.row], t.tracks)) data.append(([row], track))
for n in playlist_db.notes: for note in self.playlist.notes:
data.append(([n.row], n)) data.append(([note.row], note))
# Clear playlist # Clear playlist
self.setRowCount(0) self.setRowCount(0)
@ -462,33 +366,59 @@ class PlaylistTab(QTableWidget):
for i in sorted(data, key=lambda x: x[0]): for i in sorted(data, key=lambda x: x[0]):
item = i[1] item = i[1]
if isinstance(item, Tracks): if isinstance(item, Tracks):
self.insert_track(session, item, repaint=False) self._insert_track(session, item, repaint=False)
elif isinstance(item, Notes): elif isinstance(item, Notes):
self.insert_note(session, item, repaint=False) self._insert_note(session, item, repaint=False)
# Scroll to top # Scroll to top
scroll_to = self.item(0, self.COL_INDEX) scroll_to = self.item(0, self.COL_TITLE)
self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop)
# We possibly don't need to save the playlist here, but a) row
# numbers may have changed during population, and b) it's cheap
self.save_playlist(session) self.save_playlist(session)
self.update_display() self.update_display()
def save_playlist(self, session): def save_playlist(self, session):
# TODO: V2 check
""" """
Save playlist to database. Save playlist to database.
For notes: check the database entry is correct and update it if For notes: check the database entry is correct and update it if
necessary. Playlists:Note is one:many, so there is only one notes necessary. Playlists:Note is one:many, so each note may only appear
appearance in all playlists. in one playlist.
For tracks: erase the playlist tracks and recreate. This is much For tracks: erase the playlist tracks and recreate. This is much
simpler than trying to correct any Playlists:Tracks many:many simpler than trying to implement any Playlists:Tracks many:many
errors. changes.
""" """
# We need to add ourself to the session # TODO: do we need to add ourself to the session?
playlist_db = session.query(Playlists).filter( insp = inspect(self.playlist)
Playlists.id == self.id).one() transient = insp.transient
pending = insp.pending
persistent = insp.persistent
deleted = insp.deleted
detached = insp.detached
if transient:
DEBUG("playlist is transient")
session.add(self.playlist)
elif pending:
DEBUG("playlist is pending")
elif persistent:
DEBUG("playlist is persistent")
elif deleted:
DEBUG("playlist is deleted")
elif detached:
DEBUG("playlist is detached")
session.add(self.playlist)
assert inspect(self.playlist) == pending
else:
DEBUG("Can't find state of playlist")
# TODO: hopefully we don't need to do this:
# playlist = session.query(Playlists).filter(
# Playlists.id == TODO: self.id).one()
# Notes first # Notes first
# Create dictionaries indexed by note_id # Create dictionaries indexed by note_id
@ -498,54 +428,46 @@ class PlaylistTab(QTableWidget):
# PlaylistTab # PlaylistTab
for row in notes_rows: for row in notes_rows:
note_id = self._get_row_id(row) playlist_notes[note.id] = self._get_row_content(row)
if not note_id:
DEBUG(f"(_save_playlist(): no COL_INDEX data in row {row}")
continue
playlist_notes[note_id] = row
# Database # Database
for note in playlist_db.notes: for note in self.playlist.notes:
database_notes[note.id] = note.row database_notes[note.id] = note
# Notes to add to database # We don't need to check for notes to add to the database as
# This should never be needed as notes are added to a specific # they can't exist in the playlist without being in the database
# playlist upon creation # and pointing at this playlist.
for note_id in set(playlist_notes.keys()) - set(database_notes.keys()):
ERROR(
f"_save_playlist(): Note.id={note_id} "
f"missing from playlist {playlist_db} in database"
)
# Notes to remove from database # Notes to remove from database
for note_id in set(database_notes.keys()) - set(playlist_notes.keys()): for note_id in set(database_notes.keys()) - set(playlist_notes.keys()):
DEBUG( DEBUG(
f"_save_playlist(): Delete note note_id={note_id} " "_save_playlist(): "
f"from playlist {playlist_db} in database" f"Delete {note_id=} from {playlist=} in database"
) )
Notes.delete_note(session, note_id) database_notes[note_id].delete_note(session)
# Note rows to update in playlist database # Note rows to update in playlist database
for note_id in set(playlist_notes.keys()) & set(database_notes.keys()): for note_id in set(playlist_notes.keys()) & set(database_notes.keys()):
if playlist_notes[note_id] != database_notes[note_id]: if playlist_notes[note_id].row != database_notes[note_id].row:
DEBUG( DEBUG(
f"_save_playlist(): Update database note.id {note_id} " f"_save_playlist(): Update notes row in database "
f"from row={database_notes[note_id]} to " f"from {database_notes[note_id]=} "
f"row={playlist_notes[note_id]}" f"to {playlist_notes[note_id]=}"
) )
Notes.update_note(session, note_id, playlist_notes[note_id]) database_notes[note_id].update_note(
session, row=playlist_notes[note_id].row)
# Tracks # Tracks
# Remove all tracks for us in datbase # Remove all tracks from this playlist
playlist_db.remove_all_tracks(session) self.playlist.remove_all_tracks(session)
# Iterate on-screen playlist and add tracks back in # Iterate on-screen playlist and add tracks back in
for row in range(self.rowCount()): for row in range(self.rowCount()):
if row in notes_rows: if row in notes_rows:
continue continue
playlist_db.add_track( playlist.add_track(session, track, row)
session, self.id, self._get_row_id(row), row)
def select_next_row(self): def select_next_row(self):
# TODO: V2 check
""" """
Select next or first row. Don't select notes. Wrap at last row. Select next or first row. Don't select notes. Wrap at last row.
""" """
@ -578,6 +500,7 @@ class PlaylistTab(QTableWidget):
self.selectRow(row) self.selectRow(row)
def select_played_tracks(self): def select_played_tracks(self):
# TODO: V2 check
"""Select all played tracks in playlist""" """Select all played tracks in playlist"""
# Need to allow multiple rows to be selected # Need to allow multiple rows to be selected
@ -593,6 +516,7 @@ class PlaylistTab(QTableWidget):
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
def select_previous_row(self): def select_previous_row(self):
# TODO: V2 check
""" """
Select previous or last track. Don't select notes. Wrap at first row. Select previous or last track. Don't select notes. Wrap at first row.
""" """
@ -626,6 +550,7 @@ class PlaylistTab(QTableWidget):
self.selectRow(row) self.selectRow(row)
def select_unplayed_tracks(self): def select_unplayed_tracks(self):
# TODO: V2 check
"Select all unplayed tracks in playlist" "Select all unplayed tracks in playlist"
# Need to allow multiple rows to be selected # Need to allow multiple rows to be selected
@ -645,6 +570,7 @@ class PlaylistTab(QTableWidget):
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
def set_selected_as_next(self): def set_selected_as_next(self):
# TODO: V2 check
""" """
Sets the selected track as the next track. Sets the selected track as the next track.
""" """
@ -656,12 +582,10 @@ class PlaylistTab(QTableWidget):
self.update_display() self.update_display()
def update_display(self, clear_selection=True): def update_display(self, clear_selection=True):
# TODO: V2 check
"Set row colours, fonts, etc" "Set row colours, fonts, etc"
DEBUG( DEBUG(f"playlist.update_display [{self.playlist=}]")
f"playlist[{self.id}:{self.name}]."
f"_repaint(clear_selection={clear_selection}"
)
with Session() as session: with Session() as session:
if clear_selection: if clear_selection:
@ -694,6 +618,40 @@ class PlaylistTab(QTableWidget):
# current track. # current track.
if row in notes: if row in notes:
# TODO: check whether note has a time
# # Does note end with a time?
# start_time = None
# try:
# start_time = datetime.strptime(note.note[-9:], " %H:%M:%S").time()
# DEBUG(
# f"playlist.inset_note(): Note contains valid time={start_time}"
# )
# except ValueError:
# DEBUG(
# f"playlist.inset_note(): Note on row {row} ('{note.note}') "
# "does not contain valid time"
# )
# self.insertRow(row)
# item = QTableWidgetItem(note)
# self.setItem(row, self.COL_INDEX, item)
# titleitem = QTableWidgetItem(note.note)
# self.setItem(row, self.COL_NOTE, titleitem)
# self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN,
# self.NOTE_COL_SPAN)
# # Add start/end times or empty items as background
# # colour won't be set for columns without items
# self._set_row_start_time(row, start_time)
# item = QTableWidgetItem()
# self.setItem(row, self.COL_END_TIME, item)
# item = QTableWidgetItem()
# self.setItem(row, self.COL_LAST_PLAYED, item)
# self._meta_set_note(row)
row_time = self._get_row_time(row) row_time = self._get_row_time(row)
if row_time: if row_time:
next_start_time = row_time next_start_time = row_time
@ -791,6 +749,7 @@ class PlaylistTab(QTableWidget):
# ########## Internally called functions ########## # ########## Internally called functions ##########
def _audacity(self, row): def _audacity(self, row):
# TODO: V2 check
"Open track in Audacity. Audacity must be already running" "Open track in Audacity. Audacity must be already running"
DEBUG(f"_audacity({row})") DEBUG(f"_audacity({row})")
@ -805,6 +764,7 @@ class PlaylistTab(QTableWidget):
open_in_audacity(track.path) open_in_audacity(track.path)
def _calculate_next_start_time(self, session, row, start): def _calculate_next_start_time(self, session, row, start):
# TODO: V2 check
"Return this row's end time given its start time" "Return this row's end time given its start time"
if start is None: if start is None:
@ -817,10 +777,12 @@ class PlaylistTab(QTableWidget):
return start + timedelta(milliseconds=duration) return start + timedelta(milliseconds=duration)
def _context_menu(self, pos): def _context_menu(self, pos):
# TODO: V2 check
self.menu.exec_(self.mapToGlobal(pos)) self.menu.exec_(self.mapToGlobal(pos))
def _copy_path(self, row): def _copy_path(self, row):
# TODO: V2 check
""" """
If passed row is track row, copy the track path to the clipboard. If passed row is track row, copy the track path to the clipboard.
Otherwise return None. Otherwise return None.
@ -840,6 +802,7 @@ class PlaylistTab(QTableWidget):
cb.setText(path, mode=cb.Clipboard) cb.setText(path, mode=cb.Clipboard)
def _cell_changed(self, row, column): def _cell_changed(self, row, column):
# TODO: V2 check
"Called when cell content has changed" "Called when cell content has changed"
if not self.editing_cell: if not self.editing_cell:
@ -880,18 +843,20 @@ class PlaylistTab(QTableWidget):
else: else:
track = Tracks.get_by_id(session, row_id) track = Tracks.get_by_id(session, row_id)
if column == self.COL_ARTIST: if column == self.COL_ARTIST:
update_meta(session, track, artist=new) track.update_artist(session, artist=new)
elif column == self.COL_TITLE: elif column == self.COL_TITLE:
update_meta(session, track, title=new) track.update_title(session, title=new)
else: else:
ERROR("_cell_changed(): unrecognised column") ERROR("_cell_changed(): unrecognised column")
def _cell_edit_started(self, row, column): def _cell_edit_started(self, row, column):
# TODO: V2 check
DEBUG(f"_cell_edit_started({row=}, {column=})") DEBUG(f"_cell_edit_started({row=}, {column=})")
self.editing_cell = True self.editing_cell = True
self.master_process.disable_play_next_controls() self.master_process.disable_play_next_controls()
def _cell_edit_ended(self): def _cell_edit_ended(self):
# TODO: V2 check
DEBUG("_cell_edit_ended()") DEBUG("_cell_edit_ended()")
self.editing_cell = False self.editing_cell = False
@ -902,6 +867,7 @@ class PlaylistTab(QTableWidget):
self.master_process.enable_play_next_controls() self.master_process.enable_play_next_controls()
def _delete_rows(self): def _delete_rows(self):
# TODO: V2 check
"Delete mutliple rows" "Delete mutliple rows"
DEBUG("playlist._delete_rows()") DEBUG("playlist._delete_rows()")
@ -937,6 +903,7 @@ class PlaylistTab(QTableWidget):
self.update_display() self.update_display()
def _drop_on(self, event): def _drop_on(self, event):
# TODO: V2 check
index = self.indexAt(event.pos()) index = self.indexAt(event.pos())
if not index.isValid(): if not index.isValid():
return self.rowCount() return self.rowCount()
@ -944,7 +911,13 @@ class PlaylistTab(QTableWidget):
return (index.row() + 1 if self._is_below(event.pos(), index) return (index.row() + 1 if self._is_below(event.pos(), index)
else index.row()) else index.row())
def _get_row_content(self, row):
"""Return content associated with this row"""
return self.item(row, self.COL_USERDATA).data(CONTENT_OBJECT)
def _get_row_id(self, row): def _get_row_id(self, row):
# TODO: V2 check
"Return item id as integer from passed row" "Return item id as integer from passed row"
if row is None: if row is None:
@ -962,6 +935,7 @@ class PlaylistTab(QTableWidget):
return None return None
def _get_row_time(self, row): def _get_row_time(self, row):
# TODO: V2 check
try: try:
if self.item(row, self.COL_START_TIME): if self.item(row, self.COL_START_TIME):
return datetime.strptime(self.item( return datetime.strptime(self.item(
@ -973,6 +947,7 @@ class PlaylistTab(QTableWidget):
return None return None
def _info_row(self, row): def _info_row(self, row):
# TODO: V2 check
"Display popup with info re row" "Display popup with info re row"
id = self._get_row_id(row) id = self._get_row_id(row)
@ -1002,7 +977,111 @@ class PlaylistTab(QTableWidget):
info.setDefaultButton(QMessageBox.Cancel) info.setDefaultButton(QMessageBox.Cancel)
info.exec() info.exec()
def _insert_note(self, session, note, repaint=True):
"""
Insert a note to playlist tab.
If a row is selected, add note above. Otherwise, add to end of
playlist.
Return the row number that track is now in.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(f"playlist.inset_note(): row={row}")
self.insertRow(row)
# Add empty items to unused columns because
# colour won't be set for columns without items
item = QTableWidgetItem()
self.setItem(row, self.COL_AUTOPLAY, item)
item = QTableWidgetItem()
self.setItem(row, self.COL_MSS, item)
# Add text of note from title column onwards
titleitem = QTableWidgetItem(note.note)
self.setItem(row, self.COL_NOTE, titleitem)
self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN,
self.NOTE_COL_SPAN)
# Attach note object to row
self._set_row_content(row, note)
# Mark row as a Note row
self._meta_set_note(row)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self.save_playlist(session)
self.update_display(clear_selection=False)
return row
def _insert_track(self, session, track, repaint=True):
"""
Insert track into playlist tab.
If a row is selected, add track above. Otherwise, add to end of
playlist.
Return the row number that track is now in.
"""
if self.selectionModel().hasSelection():
row = self.currentRow()
else:
row = self.rowCount()
DEBUG(
f"playlists.insert_track({session=}, {track=}, {repaint=}), "
f"{row=}"
)
self.insertRow(row)
# Add track details to columns
mss_item = QTableWidgetItem(str(track.start_gap))
if track.start_gap >= 500:
item.setBackground(QColor(Config.COLOUR_LONG_START))
self.setItem(row, self.COL_MSS, mss_item)
title_item = QTableWidgetItem(track.title)
self.setItem(row, self.COL_TITLE, title_item)
artist_item = QTableWidgetItem(track.artist)
self.setItem(row, self.COL_ARTIST, artist_item)
duration_item = QTableWidgetItem(helpers.ms_to_mmss(track.duration))
self.setItem(row, self.COL_DURATION, duration_item)
last_playtime = Playdates.last_played(session, track.id)
last_played_str = get_relative_date(last_playtime)
last_played_item = QTableWidgetItem(last_played_str)
self.setItem(row, self.COL_LAST_PLAYED, last_played_item)
# Add empty start and stop time because background
# colour won't be set for columns without items
start_item = QTableWidgetItem()
self.setItem(row, self.COL_START_TIME, start_item)
stop_item = QTableWidgetItem()
self.setItem(row, self.COL_STOP_TIME, stop_item)
# Attach track object to row
self._set_row_content(row, track)
# Mart track if file is unreadable
if not os.access(track.path, os.R_OK):
self._meta_set_unreadable(row)
# Scroll to new row
self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
if repaint:
self.save_playlist(session)
self.update_display(clear_selection=False)
return row
def _is_below(self, pos, index): def _is_below(self, pos, index):
# TODO: V2 check
rect = self.visualRect(index) rect = self.visualRect(index)
margin = 2 margin = 2
if pos.y() - rect.top() < margin: if pos.y() - rect.top() < margin:
@ -1016,6 +1095,7 @@ class PlaylistTab(QTableWidget):
) )
def _edit_cell(self, mi): def _edit_cell(self, mi):
# TODO: V2 check
"Called when table is double-clicked" "Called when table is double-clicked"
row = mi.row() row = mi.row()
@ -1026,6 +1106,7 @@ class PlaylistTab(QTableWidget):
self.editItem(item) self.editItem(item)
def _find_next_track_row(self, starting_row=None): def _find_next_track_row(self, starting_row=None):
# TODO: V2 check
""" """
Find next track to play. Find next track to play.
@ -1054,11 +1135,13 @@ class PlaylistTab(QTableWidget):
return None return None
def _meta_clear(self, row): def _meta_clear(self, row):
# TODO: V2 check
"Clear metadata for row" "Clear metadata for row"
self._meta_set(row, None) self._meta_set(row, None)
def _meta_clear_current(self): def _meta_clear_current(self):
# TODO: V2 check
""" """
Clear current row if there is one. There may not be if Clear current row if there is one. There may not be if
we've changed playlists we've changed playlists
@ -1069,6 +1152,7 @@ class PlaylistTab(QTableWidget):
self._meta_clear(current_row) self._meta_clear(current_row)
def _meta_clear_next(self): def _meta_clear_next(self):
# TODO: V2 check
""" """
Clear next row if there is one. There may not be if Clear next row if there is one. There may not be if
we've changed playlists we've changed playlists
@ -1079,6 +1163,8 @@ class PlaylistTab(QTableWidget):
self._meta_clear(next_row) self._meta_clear(next_row)
def _meta_find(self, metadata, one=True): def _meta_find(self, metadata, one=True):
# TODO: V2 check
""" """
Search rows for metadata. Search rows for metadata.
@ -1108,31 +1194,37 @@ class PlaylistTab(QTableWidget):
raise AttributeError(f"Multiple '{metadata}' metadata {matches}") raise AttributeError(f"Multiple '{metadata}' metadata {matches}")
def _meta_get(self, row): def _meta_get(self, row):
# TODO: V2 check
"Return row metadata" "Return row metadata"
return self.item(row, self.COL_INDEX).data(Qt.UserRole) return self.item(row, self.COL_INDEX).data(Qt.UserRole)
def _meta_get_current(self): def _meta_get_current(self):
# TODO: V2 check
"Return row marked as current, or None" "Return row marked as current, or None"
return self._meta_find("current") return self._meta_find("current")
def _meta_get_next(self): def _meta_get_next(self):
# TODO: V2 check
"Return row marked as next, or None" "Return row marked as next, or None"
return self._meta_find("next") return self._meta_find("next")
def _meta_get_notes(self): def _meta_get_notes(self):
# TODO: V2 check
"Return rows marked as notes, or None" "Return rows marked as notes, or None"
return self._meta_find("note", one=False) return self._meta_find("note", one=False)
def _meta_get_unreadable(self): def _meta_get_unreadable(self):
# TODO: V2 check
"Return rows marked as unreadable, or None" "Return rows marked as unreadable, or None"
return self._meta_find("unreadable", one=False) return self._meta_find("unreadable", one=False)
def _meta_set_current(self, row): def _meta_set_current(self, row):
# TODO: V2 check
"Mark row as current track" "Mark row as current track"
old_current = self._meta_get_current() old_current = self._meta_get_current()
@ -1141,6 +1233,7 @@ class PlaylistTab(QTableWidget):
self._meta_set(row, "current") self._meta_set(row, "current")
def _meta_set_next(self, row): def _meta_set_next(self, row):
# TODO: V2 check
"Mark row as next track" "Mark row as next track"
old_next = self._meta_get_next() old_next = self._meta_get_next()
@ -1149,16 +1242,19 @@ class PlaylistTab(QTableWidget):
self._meta_set(row, "next") self._meta_set(row, "next")
def _meta_set_note(self, row): def _meta_set_note(self, row):
# TODO: V2 check
"Mark row as note" "Mark row as note"
self._meta_set(row, "note") self._meta_set(row, "note")
def _meta_set_unreadable(self, row): def _meta_set_unreadable(self, row):
# TODO: V2 check
"Mark row as unreadable" "Mark row as unreadable"
self._meta_set(row, "unreadable") self._meta_set(row, "unreadable")
def _meta_set(self, row, metadata): def _meta_set(self, row, metadata):
# TODO: V2 check
"Set row metadata" "Set row metadata"
if self.item(row, self.COL_TITLE): if self.item(row, self.COL_TITLE):
@ -1166,7 +1262,7 @@ class PlaylistTab(QTableWidget):
else: else:
title = "" title = ""
DEBUG( DEBUG(
f"playlist[{self.id}:{self.name}]._meta_set(row={row}, " f"playlist[{TODO: self.id}:{TODO: self.name}]._meta_set(row={row}, "
f"title={title}, metadata={metadata})" f"title={title}, metadata={metadata})"
) )
if row is None: if row is None:
@ -1175,6 +1271,7 @@ class PlaylistTab(QTableWidget):
self.item(row, self.COL_INDEX).setData(Qt.UserRole, metadata) self.item(row, self.COL_INDEX).setData(Qt.UserRole, metadata)
def _set_next(self, row): def _set_next(self, row):
# TODO: V2 check
""" """
If passed row is track row, check track is readable and, if it is, If passed row is track row, check track is readable and, if it is,
set that track as the next track to be played and return track_id. set that track as the next track to be played and return track_id.
@ -1201,6 +1298,7 @@ class PlaylistTab(QTableWidget):
return track_id return track_id
def _rescan(self, row): def _rescan(self, row):
# TODO: V2 check
""" """
If passed row is track row, rescan it. If passed row is track row, rescan it.
Otherwise return None. Otherwise return None.
@ -1219,6 +1317,7 @@ class PlaylistTab(QTableWidget):
self._update_row(row, track) self._update_row(row, track)
def _select_event(self): def _select_event(self):
# TODO: V2 check
""" """
Called when item selection changes. Called when item selection changes.
If multiple rows are selected, display sum of durations in status bar. If multiple rows are selected, display sum of durations in status bar.
@ -1240,19 +1339,25 @@ class PlaylistTab(QTableWidget):
self.master_process.lblSumPlaytime.setText("") self.master_process.lblSumPlaytime.setText("")
def _set_column_widths(self): def _set_column_widths(self):
# TODO: V2 check
# Column widths from settings # Column widths from settings
with Session() as session: with Session() as session:
for column in range(self.columnCount()): for column in range(self.columnCount()):
# Only show column 0 in test mode # Only show column 0 in test mode
# TODO: do we need column zero? Has no width ever.
if (column == 0 and not Config.TESTMODE): if (column == 0 and not Config.TESTMODE):
self.setColumnWidth(0, 0) self.setColumnWidth(0, 0)
else: else:
name = f"playlist_col_{str(column)}_width" name = f"playlist_col_{str(column)}_width"
record = Settings.get_int(session, name) record = Settings.get_int(session, name)
if record.f_int is not None: if record and record.f_int is not None:
self.setColumnWidth(column, record.f_int) self.setColumnWidth(column, record.f_int)
else:
self.setColumnWidth(column,
Config.DEFAULT_COLUMN_WIDTH)
def _set_row_bold(self, row, bold=True): def _set_row_bold(self, row, bold=True):
# TODO: V2 check
boldfont = QFont() boldfont = QFont()
boldfont.setBold(bold) boldfont.setBold(bold)
for j in range(self.columnCount()): for j in range(self.columnCount()):
@ -1260,14 +1365,22 @@ class PlaylistTab(QTableWidget):
self.item(row, j).setFont(boldfont) self.item(row, j).setFont(boldfont)
def _set_row_colour(self, row, colour): def _set_row_colour(self, row, colour):
# TODO: V2 check
for j in range(2, self.columnCount()): for j in range(2, self.columnCount()):
if self.item(row, j): if self.item(row, j):
self.item(row, j).setBackground(colour) self.item(row, j).setBackground(colour)
def _set_row_content(self, row, content):
"""Set content associated with this row"""
self.item(row, self.COL_USERDATA).setData(CONTENT_OBJECT, content)
def _set_row_not_bold(self, row): def _set_row_not_bold(self, row):
# TODO: V2 check
self._set_row_bold(row, False) self._set_row_bold(row, False)
def _set_row_end_time(self, row, time): def _set_row_end_time(self, row, time):
# TODO: V2 check
"Set passed row end time to passed time" "Set passed row end time to passed time"
try: try:
time_str = time.strftime("%H:%M:%S") time_str = time.strftime("%H:%M:%S")
@ -1277,6 +1390,7 @@ class PlaylistTab(QTableWidget):
self.setItem(row, self.COL_END_TIME, item) self.setItem(row, self.COL_END_TIME, item)
def _set_row_start_time(self, row, time): def _set_row_start_time(self, row, time):
# TODO: V2 check
"""Set passed row start time to passed time""" """Set passed row start time to passed time"""
try: try:
time_str = time.strftime("%H:%M:%S") time_str = time.strftime("%H:%M:%S")
@ -1302,6 +1416,7 @@ class PlaylistTab(QTableWidget):
return False return False
def _update_row(self, row, track): def _update_row(self, row, track):
# TODO: V2 check
""" """
Update the passed row with info from the passed track. Update the passed row with info from the passed track.
""" """

View File

@ -5,10 +5,10 @@ import os
import shutil import shutil
import tempfile import tempfile
from app.config import Config from config import Config
from app.helpers import show_warning from helpers import show_warning
from app.log import DEBUG, INFO from log import DEBUG, INFO
from app.models import Notes, Playdates, Session, Tracks from models import Notes, Playdates, Session, Tracks
from mutagen.flac import FLAC from mutagen.flac import FLAC
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from pydub import AudioSegment, effects from pydub import AudioSegment, effects

19
poetry.lock generated
View File

@ -374,6 +374,21 @@ tomli = ">=1.0.0"
[package.extras] [package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-qt"
version = "4.0.2"
description = "pytest support for PyQt and PySide applications"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pytest = ">=3.0.0"
[package.extras]
dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"]
[[package]] [[package]]
name = "python-vlc" name = "python-vlc"
version = "3.0.12118" version = "3.0.12118"
@ -829,6 +844,10 @@ pytest = [
{file = "pytest-7.0.0-py3-none-any.whl", hash = "sha256:42901e6bd4bd4a0e533358a86e848427a49005a3256f657c5c8f8dd35ef137a9"}, {file = "pytest-7.0.0-py3-none-any.whl", hash = "sha256:42901e6bd4bd4a0e533358a86e848427a49005a3256f657c5c8f8dd35ef137a9"},
{file = "pytest-7.0.0.tar.gz", hash = "sha256:dad48ffda394e5ad9aa3b7d7ddf339ed502e5e365b1350e0af65f4a602344b11"}, {file = "pytest-7.0.0.tar.gz", hash = "sha256:dad48ffda394e5ad9aa3b7d7ddf339ed502e5e365b1350e0af65f4a602344b11"},
] ]
pytest-qt = [
{file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"},
{file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"},
]
python-vlc = [ python-vlc = [
{file = "python-vlc-3.0.12118.tar.gz", hash = "sha256:566f2f7c303f6800851cacc016df1c6eeec094ad63e0a49d87db9d698094f1fb"}, {file = "python-vlc-3.0.12118.tar.gz", hash = "sha256:566f2f7c303f6800851cacc016df1c6eeec094ad63e0a49d87db9d698094f1fb"},
{file = "python_vlc-3.0.12118-py3-none-any.whl", hash = "sha256:f88be06c6f819a4db2de1c586b193b5df1410ff10fca33b8c6f4e56037c46f7b"}, {file = "python_vlc-3.0.12118-py3-none-any.whl", hash = "sha256:f88be06c6f819a4db2de1c586b193b5df1410ff10fca33b8c6f4e56037c46f7b"},

View File

@ -23,6 +23,7 @@ mypy = "^0.931"
pytest = "^7.0.0" pytest = "^7.0.0"
ipdb = "^0.13.9" ipdb = "^0.13.9"
sqlalchemy-stubs = "^0.4" sqlalchemy-stubs = "^0.4"
pytest-qt = "^4.0.2"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

View File

@ -46,7 +46,6 @@ def test_notecolours_get_colour_none(session):
def test_notecolours_get_colour_match(session): def test_notecolours_get_colour_match(session):
note_colour = "#abcdef" note_colour = "#abcdef"
nc = NoteColours(session, substring="sub", colour=note_colour) nc = NoteColours(session, substring="sub", colour=note_colour)
assert nc assert nc
@ -146,7 +145,6 @@ def test_playdates_remove_track(session):
def test_playlist_create(session): def test_playlist_create(session):
playlist = Playlists(session, "my playlist") playlist = Playlists(session, "my playlist")
assert playlist assert playlist
@ -164,7 +162,6 @@ def test_playlist_add_note(session):
def test_playlist_add_track(session): def test_playlist_add_track(session):
# We need a playlist # We need a playlist
playlist = Playlists(session, "my playlist") playlist = Playlists(session, "my playlist")
@ -202,8 +199,27 @@ def test_playlist_tracks(session):
assert tracks[track2_row] == track2 assert tracks[track2_row] == track2
def test_playlist_open_and_close(session): def test_playlist_notes(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
# We need two notes
note1_text = "note1 text"
note1_row = 11
note1 = Notes(session, playlist.id, note1_row, note1_text)
note2_text = "note2 text"
note2_row = 19
note2 = Notes(session, playlist.id, note2_row, note2_text)
notes = playlist.notes
assert note1_text in [n.note for n in notes]
assert note1_row in [n.row for n in notes]
assert note2_text in [n.note for n in notes]
assert note2_row in [n.row for n in notes]
def test_playlist_open_and_close(session):
# We need a playlist # We need a playlist
playlist = Playlists(session, "my playlist") playlist = Playlists(session, "my playlist")

View File

@ -1,8 +1,10 @@
from app.playlists import PlaylistTab from app.playlists import PlaylistTab
from app.models import Playlists
def test_init(session): def test_init(qtbot, session):
"""Just check we can create a playlist""" """Just check we can create a playlist_tab"""
playlist = PlaylistTab() playlist = Playlists(session, "my playlist")
assert playlist playlist_tab = PlaylistTab(None, session, playlist)
assert playlist_tab