Compare commits

..

5 Commits

Author SHA1 Message Date
Keith Edmunds
fd0d3e6e1f Move cron jobs to musicmuster.py 2022-04-18 14:53:57 +01:00
Keith Edmunds
70287d15a6 Implement search of playlist 2022-04-17 13:10:21 +01:00
Keith Edmunds
871598efe6 Code cleanup 2022-04-17 11:30:49 +01:00
Keith Edmunds
f143bd7fe9 Rebase from dev 2022-04-17 10:44:15 +01:00
Keith Edmunds
9e65eef621 Fix next start times
Fixes #113
2022-04-17 10:42:20 +01:00
8 changed files with 167 additions and 60 deletions

View File

@ -46,6 +46,7 @@ elif MM_ENV == 'DEVELOPMENT':
else:
raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
DEBUG(f"Using {dbname} database", True)
MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
engine = sqlalchemy.create_engine(

View File

@ -59,7 +59,7 @@ def log_uncaught_exceptions(ex_cls, ex, tb):
sys.excepthook = log_uncaught_exceptions
def DEBUG(msg, force_stderr=False):
def DEBUG(msg, force_stderr=True):
"""
Outupt a log message at level DEBUG. If force_stderr is True,
output this message to stderr regardless of default stderr level

View File

@ -129,8 +129,8 @@ class Notes(Base):
row: int = Column(Integer, nullable=False)
note: str = Column(String(256), index=False)
def __init__(
self, session: Session, playlist_id: int, row: int, text: str) -> None:
def __init__(self, session: Session, playlist_id: int,
row: int, text: str) -> None:
"""Create note"""
DEBUG(f"Notes.__init__({playlist_id=}, {row=}, {text=})")
@ -257,11 +257,6 @@ class Playlists(Base):
def __repr__(self) -> str:
return f"<Playlists(id={self.id}, name={self.name}>"
def add_note(self, session: Session, row: int, text: str) -> Notes:
"""Add note to playlist at passed row"""
return Notes(session, self.id, row, text)
def add_track(
self, session: Session, track_id: int,
row: Optional[int] = None) -> None:
@ -495,8 +490,8 @@ class Tracks(Base):
path: str,
title: Optional[str] = None,
artist: Optional[str] = None,
duration: Optional[int] = None,
start_gap: Optional[int] = None,
duration: int = 0,
start_gap: int = 0,
fade_at: Optional[int] = None,
silence_at: Optional[int] = None,
mtime: Optional[float] = None,
@ -652,5 +647,6 @@ class Tracks(Base):
session.add(self)
session.flush()
def update_path(self, newpath: str) -> None:
def update_path(self, session, newpath: str) -> None:
self.path = newpath
session.commit()

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python
import argparse
import os.path
import psutil
import sys
@ -20,6 +21,7 @@ from PyQt5.QtWidgets import (
QFileDialog,
QInputDialog,
QLabel,
QLineEdit,
QListWidgetItem,
QMainWindow,
)
@ -38,7 +40,7 @@ from ui.dlg_search_database_ui import Ui_Dialog
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist
from ui.downloadcsv_ui import Ui_DateSelect
from ui.main_window_ui import Ui_MainWindow
from utilities import create_track_from_file
from utilities import create_track_from_file, update_db
class TrackData:
@ -63,7 +65,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer: QTimer = QTimer()
self.even_tick: bool = True
self.playing: bool = False
self.connect_signals_slots()
self.disable_play_next_controls()
self.music: music.Music = music.Music()
@ -79,6 +80,9 @@ class Window(QMainWindow, Ui_MainWindow):
self.set_main_window_size()
self.lblSumPlaytime: QLabel = QLabel("")
self.statusbar.addPermanentWidget(self.lblSumPlaytime)
self.txtSearch = QLineEdit()
self.statusbar.addWidget(self.txtSearch)
self.txtSearch.setHidden(True)
self.visible_playlist_tab: Callable[[], PlaylistTab] = \
self.tabPlaylist.currentWidget
@ -87,6 +91,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.enable_play_next_controls()
self.check_audacity()
self.timer.start(Config.TIMER_MS)
self.connect_signals_slots()
def set_main_window_size(self) -> None:
"""Set size of window from database"""
@ -182,6 +187,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.actionNewPlaylist.triggered.connect(self.create_playlist)
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
self.actionPlay_next.triggered.connect(self.play_next)
self.actionSearch.triggered.connect(self.search_playlist)
self.actionSearch_database.triggered.connect(self.search_database)
self.actionSelect_next_track.triggered.connect(self.select_next_row)
self.actionSelect_played_tracks.triggered.connect(self.select_played)
@ -203,6 +209,8 @@ class Window(QMainWindow, Ui_MainWindow):
self.btnStop.clicked.connect(self.stop)
self.spnVolume.valueChanged.connect(self.change_volume)
self.tabPlaylist.tabCloseRequested.connect(self.close_tab)
self.txtSearch.returnPressed.connect(self.search_playlist_return)
self.txtSearch.textChanged.connect(self.search_playlist_update)
self.timer.timeout.connect(self.tick)
@ -621,9 +629,27 @@ class Window(QMainWindow, Ui_MainWindow):
dlg = DbDialog(self, session)
dlg.exec()
def open_playlist(self) -> None:
"""Select and activate existing playlist"""
def search_playlist(self):
"""Show text box to search playlist"""
self.disable_play_next_controls()
self.txtSearch.setHidden(False)
self.txtSearch.setFocus()
def search_playlist_return(self):
"""Close off search box when return pressed"""
self.txtSearch.setText("")
self.txtSearch.setHidden(True)
self.enable_play_next_controls()
self.visible_playlist_tab().set_filter("")
def search_playlist_update(self):
"""Update search when search string changes"""
self.visible_playlist_tab().set_filter(self.txtSearch.text())
def open_playlist(self):
with Session() as session:
playlists = Playlists.get_closed(session)
dlg = SelectPlaylistDialog(self, playlists=playlists,
@ -1049,11 +1075,34 @@ class SelectPlaylistDialog(QDialog):
if __name__ == "__main__":
try:
Base.metadata.create_all(dbconfig.engine)
app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec())
except Exception:
EXCEPTION("Unhandled Exception caught by musicmuster.main()")
p = argparse.ArgumentParser()
# Only allow at most one option to be specified
group = p.add_mutually_exclusive_group()
group.add_argument('-u', '--update',
action="store_true", dest="update",
default=False, help="Update database")
# group.add_argument('-f', '--full-update',
# action="store_true", dest="full_update",
# default=False, help="Update database")
# group.add_argument('-i', '--import', dest="fname", help="Input file")
args = p.parse_args()
# Run as required
if args.update:
DEBUG("Updating database")
with Session() as session:
update_db(session)
# elif args.full_update:
# DEBUG("Full update of database")
# with Session() as session:
# full_update_db(session)
else:
# Normal run
try:
Base.metadata.create_all(dbconfig.engine)
app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec())
except Exception:
EXCEPTION("Unhandled Exception caught by musicmuster.main()")

View File

@ -67,6 +67,7 @@ class PlaylistTab(QTableWidget):
# Qt.UserRoles
ROW_METADATA = Qt.UserRole
CONTENT_OBJECT = Qt.UserRole + 1
ROW_DURATION = Qt.UserRole + 2
def __init__(self, musicmuster: QMainWindow, session: Session,
playlist_id: int, *args, **kwargs):
@ -135,6 +136,7 @@ class PlaylistTab(QTableWidget):
self.itemSelectionChanged.connect(self._select_event)
self.row_filter: Optional[str] = None
self.editing_cell: bool = False
self.selecting_in_progress = False
self.cellChanged.connect(self._cell_changed)
@ -358,6 +360,7 @@ class PlaylistTab(QTableWidget):
duration_item: QTableWidgetItem = QTableWidgetItem(
helpers.ms_to_mmss(track.duration)
)
self._set_row_duration(row, track.duration)
self.setItem(row, self.COL_DURATION, duration_item)
last_playtime: Optional[datetime] = Playdates.last_played(
@ -662,6 +665,13 @@ class PlaylistTab(QTableWidget):
self.selecting_in_progress = False
self._select_event()
def set_filter(self, text: Optional[str]) -> None:
"""Filter rows to only show those containing text"""
self.row_filter = text
with Session() as session:
self.update_display(session)
def set_selected_as_next(self) -> None:
"""Sets the select track as next to play"""
@ -713,12 +723,6 @@ class PlaylistTab(QTableWidget):
# For unplayed tracks, if there's a 'current' or 'next'
# track marked, populate start times from then onwards. A note
# with a start time will reset the next track start time.
if current_row and next_row:
start_times_row = min(current_row, next_row)
else:
start_times_row = current_row or next_row
if not start_times_row:
start_times_row = 0
# Cycle through all rows
for row in range(self.rowCount()):
@ -727,6 +731,14 @@ class PlaylistTab(QTableWidget):
if row in notes:
# Extract note text from database to ignore section timings
note_text = self._get_row_notes_object(row, session).note
if self.row_filter:
if self.row_filter not in note_text:
self.hideRow(row)
continue
else:
self.showRow(row)
else:
self.showRow(row)
# Does the note have a start time?
row_time = self._get_note_text_time(note_text)
if row_time:
@ -766,6 +778,20 @@ class PlaylistTab(QTableWidget):
if section_start_row is not None:
section_time += track.duration
# Render current track
if self.row_filter:
try:
if (track.title
and self.row_filter not in track.title
and track.artist
and self.row_filter not in track.artist):
self.hideRow(row)
continue
else:
self.showRow(row)
except TypeError:
print(f"TypeError: {track=}")
else:
self.showRow(row)
if row == current_row:
# Set start time
self._set_row_start_time(
@ -778,8 +804,8 @@ class PlaylistTab(QTableWidget):
last_played_str)
# Calculate next_start_time
next_start_time = self._calculate_track_end_time(
track, self.current_track_start_time)
next_start_time = self._calculate_row_end_time(
row, self.current_track_start_time)
# Set end time
self._set_row_end_time(row, next_start_time)
@ -795,8 +821,9 @@ class PlaylistTab(QTableWidget):
# Render next track
if row == next_row:
# if there's a track playing, set start time from that
if current_row:
start_time = self.current_track_start_time
if current_row is not None:
start_time = self._calculate_row_end_time(
current_row, self.current_track_start_time)
else:
# No current track to base from, but don't change
# time if it's already set
@ -806,8 +833,7 @@ class PlaylistTab(QTableWidget):
self._set_row_start_time(row, start_time)
# Set end time
next_start_time = self._calculate_track_end_time(
track, start_time)
next_start_time = self._calculate_row_end_time(row, start_time)
self._set_row_end_time(row, next_start_time)
# Set colour
@ -829,10 +855,10 @@ class PlaylistTab(QTableWidget):
self._set_row_not_bold(row)
else:
# Set start/end times as we haven't played it yet
if next_start_time and row >= start_times_row:
if next_start_time:
self._set_row_start_time(row, next_start_time)
next_start_time = self._calculate_track_end_time(
track, next_start_time)
next_start_time = self._calculate_row_end_time(
row, next_start_time)
# Set end time
self._set_row_end_time(row, next_start_time)
else:
@ -868,22 +894,19 @@ class PlaylistTab(QTableWidget):
track: Tracks = self._get_row_track_object(row, session)
open_in_audacity(track.path)
@staticmethod
def _calculate_track_end_time(
track: Tracks, start: Optional[datetime]) -> Optional[datetime]:
"""Return this track's end time given its start time"""
def _calculate_row_end_time(self, row, start: Optional[datetime]) \
-> Optional[datetime]:
"""Return this row's end time given its start time"""
if start is None:
return None
if track is None:
DEBUG("_calculate_next_start_time() called with track=None")
return None
duration = track.duration
duration = self._get_row_duration(row)
return start + timedelta(milliseconds=duration)
def _context_menu(self, pos): # review
assert self.menu
self.menu.exec_(self.mapToGlobal(pos))
def _copy_path(self, row: int) -> None:
@ -1141,6 +1164,14 @@ class PlaylistTab(QTableWidget):
return self._meta_search(RowMeta.NOTE, one=False)
def _get_row_duration(self, row: int) -> int:
"""Return duration associated with this row"""
try:
return self.item(row, self.COL_USERDATA).data(self.ROW_DURATION)
except:
return 0
def _get_row_end_time(self, row) -> Optional[datetime]:
"""
Return row end time as string
@ -1551,6 +1582,13 @@ class PlaylistTab(QTableWidget):
self.item(row, self.COL_USERDATA).setData(
self.CONTENT_OBJECT, object_id)
def _set_row_duration(self, row: int, ms: int) -> None:
"""Set duration of this row in milliseconds"""
assert self.item(row, self.COL_USERDATA)
self.item(row, self.COL_USERDATA).setData(self.ROW_DURATION, ms)
def _set_row_end_time(self, row: int, time: Optional[datetime]) -> None:
"""Set passed row end time to passed time"""

View File

@ -782,8 +782,6 @@ border: 1px solid rgb(85, 87, 83);</string>
<addaction name="actionAdd_note"/>
<addaction name="action_Clear_selection"/>
<addaction name="separator"/>
<addaction name="actionSelect_previous_track"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSetNext"/>
<addaction name="separator"/>
<addaction name="actionSelect_unplayed_tracks"/>
@ -792,6 +790,11 @@ border: 1px solid rgb(85, 87, 83);</string>
<addaction name="separator"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="actionExport_playlist"/>
<addaction name="separator"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/>
<addaction name="separator"/>
<addaction name="actionSearch"/>
</widget>
<widget class="QMenu" name="menu_Music">
<property name="title">
@ -1032,6 +1035,14 @@ border: 1px solid rgb(85, 87, 83);</string>
<string>Download CSV of played tracks...</string>
</property>
</action>
<action name="actionSearch">
<property name="text">
<string>Search...</string>
</property>
<property name="shortcut">
<string>/</string>
</property>
</action>
</widget>
<resources>
<include location="icons.qrc"/>

View File

@ -448,6 +448,8 @@ class Ui_MainWindow(object):
self.actionImport.setObjectName("actionImport")
self.actionDownload_CSV_of_played_tracks = QtWidgets.QAction(MainWindow)
self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks")
self.actionSearch = QtWidgets.QAction(MainWindow)
self.actionSearch.setObjectName("actionSearch")
self.menuFile.addAction(self.actionImport)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionE_xit)
@ -462,8 +464,6 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.actionAdd_note)
self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_previous_track)
self.menuPlaylist.addAction(self.actionSelect_next_track)
self.menuPlaylist.addAction(self.actionSetNext)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_unplayed_tracks)
@ -472,6 +472,11 @@ class Ui_MainWindow(object):
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionDownload_CSV_of_played_tracks)
self.menuPlaylist.addAction(self.actionExport_playlist)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_next_track)
self.menuPlaylist.addAction(self.actionSelect_previous_track)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSearch)
self.menu_Music.addAction(self.actionPlay_next)
self.menu_Music.addAction(self.actionSkip_next)
self.menu_Music.addAction(self.actionFade)
@ -560,4 +565,6 @@ class Ui_MainWindow(object):
self.actionImport.setText(_translate("MainWindow", "Import..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks..."))
self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/"))
import icons_rc

View File

@ -5,6 +5,7 @@ import os
import shutil
import tempfile
import helpers
from config import Config
from helpers import (
fade_point,
@ -250,24 +251,26 @@ def update_db(session):
# database:
for path in list(os_paths - db_paths):
DEBUG(f"songdb.update_db: {path=} not in database")
DEBUG(f"utilities.update_db: {path=} not in database")
# is filename in database?
track = Tracks.get_by_filename(session, os.path.basename(path))
if not track:
messages.append(f"Track missing from database: {path}")
messages.append(f"{path} missing from database: {path}")
else:
# Check track info matches found track
t = get_music_info(path)
t = helpers.get_tags(path)
if t['artist'] == track.artist and t['title'] == track.title:
track.update_path(path)
print(f">>> Update {path=} for {track.title=}")
track.update_path(session, path)
else:
create_track_from_file(session, path)
# Refresh database paths
db_paths = set(Tracks.get_all_paths(session))
# Remote any tracks from database whose paths don't exist
# Remove any tracks from database whose paths don't exist
for path in list(db_paths - os_paths):
# Manage tracks listed in database but where path is invalid
DEBUG(f"Invalid {path=} in database", True)
track = Tracks.get_by_path(session, path)
messages.append(f"Remove from database: {path=} {track=}")
@ -279,12 +282,14 @@ def update_db(session):
f"File removed: {track.title=}, {track.artist=}, "
f"{track.path=}"
)
for playlist in [a.playlist for a in track.playlists]:
# Create note
Notes(session, playlist.id, pt.row, note_txt)
# TODO: this needs to call playlist.add_note() now
for playlist_track in track.playlists:
row = playlist_track.row
# Remove playlist entry
playlist.remove_track(session, pt.row)
DEBUG(f"Remove {row=} from {playlist_track.playlist_id}", True)
playlist_track.playlist.remove_track(session, row)
# Create note
DEBUG(f"Add note at {row=} to {playlist_track.playlist_id=}", True)
Notes(session, playlist_track.playlist_id, row, note_txt)
# Remove Track entry pointing to invalid path
Tracks.remove_by_path(session, path)