Compare commits

..

10 Commits

Author SHA1 Message Date
Keith Edmunds
ce21322117 Clean up last played time in update_display 2022-06-18 18:34:06 +01:00
Keith Edmunds
cc395ea0df Move notes with tracks
Fixes #106
2022-06-18 18:24:09 +01:00
Keith Edmunds
709347db6b WIP: move notes with tracks 2022-06-18 11:09:47 +01:00
Keith Edmunds
8558de82b4 Fix bug stopping right-click menu 2022-06-10 15:28:12 +01:00
Keith Edmunds
5c02f82d21 Merge branch 'mplayer' 2022-06-10 14:59:47 +01:00
Keith Edmunds
b05e6d156d Add 'play with mplayer' to right click menu
Fixes #118
2022-06-10 14:57:01 +01:00
Keith Edmunds
44e4e451ad Make session acquisition silent by default
Also suppress notification to stdout of database in use.
2022-06-10 08:44:56 +01:00
Keith Edmunds
3f609f6f2f Don't output DEBUG messages to stdout by default 2022-06-08 13:05:34 +01:00
Keith Edmunds
1888c7f00d Fix cron job
Now only reports errors but does not attempt to fix them.

Fixes #114
2022-06-05 15:18:45 +01:00
Keith Edmunds
c6d55344c7 Add 'move track to playlist' to right-click menu
Fixes #117
2022-06-05 14:30:29 +01:00
7 changed files with 202 additions and 124 deletions

View File

@ -49,6 +49,7 @@ class Config(object):
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAX_INFO_TABS = 3
MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2" # noqa E501
NORMALISE_ON_IMPORT = True

View File

@ -46,7 +46,7 @@ elif MM_ENV == 'DEVELOPMENT':
else:
raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
DEBUG(f"Using {dbname} database", True)
DEBUG(f"Using {dbname} database")
MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
engine = sqlalchemy.create_engine(
@ -64,9 +64,8 @@ def Session():
function = frame.function
lineno = frame.lineno
Session = scoped_session(sessionmaker(bind=engine))
DEBUG(f"Session acquired, {file=}, {function=}, {lineno=}, {Session=}",
True)
DEBUG(f"Session acquired, {file=}, {function=}, {lineno=}, {Session=}")
yield Session
DEBUG(" Session released", True)
DEBUG(" Session released")
Session.commit()
Session.close()

View File

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

View File

@ -153,6 +153,30 @@ class Notes(Base):
session.query(Notes).filter_by(id=self.id).delete()
session.flush()
@staticmethod
def max_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""
Return maximum notes row for passed playlist ID or None if not notes
"""
last_row = session.query(func.max(Notes.row)).filter_by(
playlist_id=playlist_id).first()
# if there are no rows, the above returns (None, ) which is True
if last_row and last_row[0] is not None:
return last_row[0]
else:
return None
def move_row(self, session: Session, row: int, to_playlist_id: int) \
-> None:
"""
Move note to another playlist
"""
self.row = row
self.playlist_id = to_playlist_id
session.commit()
@classmethod
def get_by_id(cls, session: Session, note_id: int) -> Optional["Notes"]:
"""Return note or None"""
@ -265,8 +289,8 @@ class Playlists(Base):
If row=None, add to end of playlist
"""
if not row:
row = PlaylistTracks.next_free_row(session, self.id)
if row is None:
row = self.next_free_row(session, self.id)
PlaylistTracks(session, self.id, track_id, row)
@ -318,17 +342,23 @@ class Playlists(Base):
self.last_used = datetime.now()
session.flush()
def move_track(
self, session: Session, rows: List[int],
to_playlist: "Playlists") -> None:
"""Move tracks to another playlist"""
@staticmethod
def next_free_row(session: Session, playlist_id: int) -> int:
"""Return next free row for this playlist"""
for row in rows:
track = self.tracks[row]
to_playlist.add_track(session, track.id)
del self.tracks[row]
max_notes_row = Notes.max_used_row(session, playlist_id)
max_tracks_row = PlaylistTracks.max_used_row(session, playlist_id)
session.flush()
if max_notes_row is not None and max_tracks_row is not None:
return max(max_notes_row, max_tracks_row) + 1
if max_notes_row is None and max_tracks_row is None:
return 0
if max_notes_row is None:
return max_tracks_row + 1
else:
return max_notes_row + 1
def remove_all_tracks(self, session: Session) -> None:
"""
@ -387,48 +417,30 @@ class PlaylistTracks(Base):
session.flush()
@staticmethod
def next_free_row(session: Session, playlist_id: int) -> int:
"""Return next free row number"""
row: int
def max_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""
Return highest track row number used or None if there are no
tracks
"""
last_row = session.query(
func.max(PlaylistTracks.row)
).filter_by(playlist_id=playlist_id).first()
# if there are no rows, the above returns (None, ) which is True
if last_row and last_row[0] is not None:
row = last_row[0] + 1
return last_row[0]
else:
row = 0
return row
return None
@staticmethod
def move_rows(
session: Session, rows: List[int], from_playlist_id: int,
to_playlist_id: int) -> None:
"""Move rows between playlists"""
# A constraint deliberately blocks duplicate (playlist_id, row)
# entries in database; however, unallocated rows in the database
# are fine (ie, we can have rows 1, 4, 6 and no 2, 3, 5).
# Unallocated rows will be automatically removed when the
# playlist is saved.
lowest_source_row: int = min(rows)
first_destination_free_row = PlaylistTracks.next_free_row(
session, to_playlist_id)
# Calculate offset that will put the lowest row number being
# moved at the first free row in destination playlist
offset = first_destination_free_row - lowest_source_row
def move_row(session: Session, from_row: int, from_playlist_id: int,
to_row: int, to_playlist_id: int) -> None:
"""Move row to another playlist"""
session.query(PlaylistTracks).filter(
PlaylistTracks.playlist_id == from_playlist_id,
PlaylistTracks.row.in_(rows)
).update({'playlist_id': to_playlist_id,
'row': PlaylistTracks.row + offset},
False
)
PlaylistTracks.row == from_row).update(
{'playlist_id': to_playlist_id, 'row': to_row}, False)
class Settings(Base):

View File

@ -1,7 +1,6 @@
#!/usr/bin/env python
import argparse
import os.path
import psutil
import sys
import threading
@ -34,8 +33,7 @@ import helpers
import music
from config import Config
from models import (Base, Playdates, Playlists, PlaylistTracks,
Settings, Tracks)
from models import (Base, Playdates, Playlists, Settings, Tracks)
from playlists import PlaylistTab
from sqlalchemy.orm.exc import DetachedInstanceError
from ui.dlg_search_database_ui import Ui_Dialog
@ -504,10 +502,8 @@ class Window(QMainWindow, Ui_MainWindow):
return
destination_playlist = dlg.playlist
# Update database for both source and destination playlists
rows = visible_tab.get_selected_rows()
PlaylistTracks.move_rows(session, rows, source_playlist.id,
destination_playlist.id)
self.visible_playlist_tab().move_selected_to_playlist(
session, destination_playlist.id)
# Update destination playlist_tab if visible (if not visible, it
# will be re-populated when it is opened)
@ -527,9 +523,6 @@ class Window(QMainWindow, Ui_MainWindow):
destination_visible_playlist_tab.populate(
session, dlg.playlist.id)
# Update source playlist
self.visible_playlist_tab().remove_rows(rows)
def open_info_tabs(self) -> None:
"""
Ensure we have info tabs for next and current track titles
@ -1077,6 +1070,7 @@ class SelectPlaylistDialog(QDialog):
self.ui.buttonBox.accepted.connect(self.open)
self.ui.buttonBox.rejected.connect(self.close)
self.session = session
self.playlist = None
self.plid = None
record = Settings.get_int_settings(

View File

@ -19,6 +19,8 @@ from PyQt5.QtWidgets import (
import helpers
import os
import re
import subprocess
import threading
from config import Config
from datetime import datetime, timedelta
@ -28,6 +30,7 @@ from models import (
Notes,
Playdates,
Playlists,
PlaylistTracks,
Settings,
Tracks,
NoteColours
@ -148,7 +151,7 @@ class PlaylistTab(QTableWidget):
self.populate(session, self.playlist_id)
def __repr__(self) -> str:
return (f"<PlaylistTab(id={self.playlist_id}")
return f"<PlaylistTab(id={self.playlist_id}"
# ########## Events ##########
@ -224,6 +227,11 @@ class PlaylistTab(QTableWidget):
act_info.triggered.connect(lambda: self._info_row(row))
self.menu.addSeparator()
if row not in self._get_notes_rows():
act_mplayer = self.menu.addAction(
"Play track with mplayer")
act_mplayer.triggered.connect(
lambda: self._mplayer(row))
self.menu.addSeparator()
if not current and not next_row:
act_setnext = self.menu.addAction("Set next")
with Session() as session:
@ -240,6 +248,8 @@ class PlaylistTab(QTableWidget):
act_audacity.triggered.connect(
lambda: self._audacity(row))
if not current and not next_row:
act_move = self.menu.addAction('Move to playlist...')
act_move.triggered.connect(self.musicmuster.move_selected)
self.menu.addSeparator()
act_delete = self.menu.addAction('Delete')
act_delete.triggered.connect(self._delete_rows)
@ -305,10 +315,10 @@ class PlaylistTab(QTableWidget):
return self.selectionModel().selectedRows()[0].row()
def get_selected_rows(self) -> List[int]:
"""Return a list of selected row numbers"""
"""Return a sorted list of selected row numbers"""
rows = self.selectionModel().selectedRows()
return [row.row() for row in rows]
return sorted([row.row() for row in rows])
def get_selected_title(self) -> Optional[str]:
"""Return title of selected row or None"""
@ -389,21 +399,48 @@ class PlaylistTab(QTableWidget):
self.save_playlist(session)
self.update_display(session, clear_selection=False)
def remove_rows(self, rows) -> None:
"""Remove rows passed in rows list"""
def move_selected_to_playlist(self, session: Session, playlist_id: int) \
-> None:
"""
Move selected rows and any immediately preceding notes to
other playlist
"""
# Row number will change as we delete rows so remove them in
# reverse order.
notes_rows = self._get_notes_rows()
destination_row = Playlists.next_free_row(session, playlist_id)
rows_to_remove = []
for row in self.get_selected_rows():
if row in notes_rows:
note_obj = self._get_row_notes_object(row, session)
note_obj.move_row(session, destination_row, playlist_id)
else:
# For tracks, check for a preceding notes row and move
# that as well if it exists
if row - 1 in notes_rows:
note_obj = self._get_row_notes_object(row - 1, session)
note_obj.move_row(session, destination_row, playlist_id)
destination_row += 1
rows_to_remove.append(row - 1)
# Move track
PlaylistTracks.move_row(
session, row, self.playlist_id,
destination_row, playlist_id
)
destination_row += 1
rows_to_remove.append(row)
# Remove rows. Row number will change as we delete rows so
# remove them in reverse order.
try:
self.selecting_in_progress = True
for row in sorted(rows, reverse=True):
for row in sorted(rows_to_remove, reverse=True):
self.removeRow(row)
finally:
self.selecting_in_progress = False
self._select_event()
with Session() as session:
self.save_playlist(session)
self.update_display(session)
@ -573,6 +610,7 @@ class PlaylistTab(QTableWidget):
track_id: int = self.item(
row, self.COL_USERDATA).data(self.CONTENT_OBJECT)
playlist.add_track(session, track_id, row)
session.commit()
def select_next_row(self) -> None:
"""
@ -847,8 +885,7 @@ class PlaylistTab(QTableWidget):
# This is a track row other than next or current
if row in played:
# Played today, so update last played column
last_playedtime = Playdates.last_played(
session, track.id)
last_playedtime = track.lastplayed
last_played_str = get_relative_date(last_playedtime)
self.item(row, self.COL_LAST_PLAYED).setText(
last_played_str)
@ -1105,6 +1142,11 @@ class PlaylistTab(QTableWidget):
return False
def _get_notes_rows(self) -> List[int]:
"""Return rows marked as notes, or None"""
return self._meta_search(RowMeta.NOTE, one=False)
def _find_next_track_row(self, starting_row: int = None) -> Optional[int]:
"""
Find next track to play. If a starting row is given, start there;
@ -1164,11 +1206,6 @@ class PlaylistTab(QTableWidget):
except ValueError:
return None
def _get_notes_rows(self) -> List[int]:
"""Return rows marked as notes, or None"""
return self._meta_search(RowMeta.NOTE, one=False)
def _get_row_duration(self, row: int) -> int:
"""Return duration associated with this row"""
@ -1418,6 +1455,21 @@ class PlaylistTab(QTableWidget):
self.item(row, self.COL_USERDATA).setData(
self.ROW_METADATA, new_metadata)
def _mplayer(self, row: int) -> None:
"""Play track with mplayer"""
DEBUG(f"_mplayer({row})")
if row in self._get_notes_rows():
return None
with Session() as session:
track: Tracks = self._get_row_track_object(row, session)
cmd_list = ['gmplayer', '-vc', 'null', '-vo', 'null', track.path]
thread = threading.Thread(
target=self._run_subprocess, args=(cmd_list,))
thread.start()
def _rescan(self, row: int) -> None:
"""
If passed row is track row, rescan it.
@ -1433,6 +1485,11 @@ class PlaylistTab(QTableWidget):
track.rescan(session)
self._update_row(session, row, track)
def _run_subprocess(self, args):
"""Run args in subprocess"""
subprocess.call(args)
def _set_current_track_row(self, row: int) -> None:
"""Mark this row as current track"""

View File

@ -204,7 +204,9 @@ def update_db(session):
Repopulate database
"""
# Search for tracks in only one of directory and database
# Search for tracks that are in the music directory but not the datebase
# Check all paths in database exist
# If issues found, write to stdout but do not try to resolve them
db_paths = set(Tracks.get_all_paths(session))
@ -217,61 +219,74 @@ def update_db(session):
os_paths_list.append(path)
os_paths = set(os_paths_list)
# If a track is moved, only the path will have changed.
# For any files we have found whose paths are not in the database,
# check to see whether the filename (basename) is present in the
# database:
# Find any files in music directory that are not in database
files_not_in_db = list(os_paths - db_paths)
for path in list(os_paths - db_paths):
DEBUG(f"utilities.update_db: {path=} not in database")
# is filename in database with a different path?
track = Tracks.get_by_filename(session, os.path.basename(path))
if not track:
messages.append(f"{path} missing from database: {path}")
else:
# Check track info matches found track
t = helpers.get_tags(path)
if t['artist'] == track.artist and t['title'] == track.title:
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))
# Remove any tracks from database whose paths don't exist
# Find paths in database missing in music directory
paths_not_found = []
missing_file_count = 0
more_files_to_report = False
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)
if missing_file_count >= Config.MAX_MISSING_FILES_TO_REPORT:
more_files_to_report = True
break
missing_file_count += 1
track = Tracks.get_by_path(session, path)
messages.append(f"Remove from database: {path=} {track=}")
if not track:
ERROR(f"update_db: {path} not found in db")
continue
# Remove references from Playdates
Playdates.remove_track(session, track.id)
# Replace playlist entries with a note
note_txt = (
f"File removed: {track.title=}, {track.artist=}, "
f"{track.path=}"
)
for playlist_track in track.playlists:
row = playlist_track.row
# Remove playlist entry
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)
paths_not_found.append(track)
# Output messages (so if running via cron, these will get sent to
# user)
if messages:
print("Messages")
print("\n".join(messages))
if files_not_in_db:
print("Files in music directory but not in database")
print("--------------------------------------------")
print("\n".join(files_not_in_db))
print("\n")
if paths_not_found:
print("Invalid paths in database")
print("-------------------------")
for t in paths_not_found:
print(f"""
Track ID: {t.id}
Path: {t.path}
Title: {t.title}
Artist: {t.artist}
""")
if more_files_to_report:
print("There were more paths than listed that were not found")
# Spike
#
# # 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=}")
#
# # Remove references from Playdates
# Playdates.remove_track(session, track.id)
#
# # Replace playlist entries with a note
# note_txt = (
# f"File removed: {track.title=}, {track.artist=}, "
# f"{track.path=}"
# )
# for playlist_track in track.playlists:
# row = playlist_track.row
# # Remove playlist entry
# 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)
if __name__ == '__main__' and '__file__' in globals():
main()