musicmuster/app/musicmuster.py
2022-12-23 21:27:06 +00:00

1676 lines
59 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
from log import log
import argparse
import stackprinter # type: ignore
import subprocess
import sys
import threading
from datetime import datetime, timedelta
from time import sleep
from typing import List, Optional
from PyQt5.QtCore import pyqtSignal, QDate, QEvent, Qt, QSize, QTime, QTimer
from PyQt5.QtGui import QColor, QFont, QPalette, QResizeEvent
from PyQt5.QtWidgets import (
QApplication,
QDialog,
QFileDialog,
QInputDialog,
QLabel,
QLineEdit,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPushButton,
QProgressBar,
)
from dbconfig import engine, Session
import helpers
import music
from models import (
Base,
Carts,
Playdates,
PlaylistRows,
Playlists,
Settings,
Tracks
)
from config import Config
from playlists import PlaylistTab
from sqlalchemy.orm.exc import DetachedInstanceError
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
from ui.dlg_search_database_ui import Ui_Dialog # type: ignore
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # type: ignore
from utilities import check_db, update_bitrates
class CartButton(QPushButton):
"""Button for playing carts"""
progress = pyqtSignal(int)
def __init__(self, parent: QMainWindow, cart: Carts):
"""Create a cart pushbutton and set it disabled"""
super().__init__(parent)
self.parent = parent
self.cart_id = cart.id
if cart.path and cart.enabled and not cart.duration:
tags = helpers.get_tags(cart.path)
cart.duration = tags['duration']
self.duration = cart.duration
self.path = cart.path
self.player = None
self.is_playing = False
self.setEnabled(True)
self.setMinimumSize(QSize(147, 61))
font = QFont()
font.setPointSize(14)
self.setFont(font)
self.setObjectName("cart_" + str(cart.cart_number))
self.pgb = QProgressBar(self, textVisible=False)
self.pgb.setVisible(False)
palette = self.pgb.palette()
palette.setColor(QPalette.Highlight,
QColor(Config.COLOUR_CART_PROGRESSBAR))
self.pgb.setPalette(palette)
self.pgb.setGeometry(0, 0, self.width(), 10)
self.pgb.setMinimum(0)
self.pgb.setMaximum(1)
self.pgb.setValue(0)
self.progress.connect(self.pgb.setValue)
def __repr__(self) -> str:
return (
f"<CartButton(cart_id={self.cart_id} "
f"path={self.path}, is_playing={self.is_playing}>"
)
def event(self, event: QEvent) -> bool:
"""Allow right click even when button is disabled"""
if event.type() == QEvent.MouseButtonRelease:
if event.button() == Qt.RightButton:
self.parent.cart_edit(self, event)
return True
return super().event(event)
def resizeEvent(self, event: QResizeEvent) -> None:
"""Resize progess bar when button size changes"""
self.pgb.setGeometry(0, 0, self.width(), 10)
class TrackData:
def __init__(self, track):
self.id = track.id
self.title = track.title
self.artist = track.artist
self.duration = track.duration
self.start_gap = track.start_gap
self.fade_at = track.fade_at
self.silence_at = track.silence_at
self.path = track.path
self.mtime = track.mtime
class Window(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setupUi(self)
self.timer: QTimer = QTimer()
self.even_tick: bool = True
self.playing: bool = False
self.music: music.Music = music.Music()
self.current_track: Optional[TrackData] = None
self.current_track_playlist_tab: Optional[PlaylistTab] = None
self.current_track_end_time = None
self.next_track: Optional[TrackData] = None
self.next_track_playlist_tab: Optional[PlaylistTab] = None
self.previous_track: Optional[TrackData] = None
self.previous_track_position: Optional[int] = None
self.selected_plrs = None
# Set colours that will be used by playlist row stripes
palette = QPalette()
palette.setColor(QPalette.Base, QColor(Config.COLOUR_EVEN_PLAYLIST))
palette.setColor(QPalette.AlternateBase,
QColor(Config.COLOUR_ODD_PLAYLIST))
self.setPalette(palette)
self.set_main_window_size()
self.lblSumPlaytime = QLabel("")
self.statusbar.addPermanentWidget(self.lblSumPlaytime)
self.txtSearch = QLineEdit()
self.statusbar.addWidget(self.txtSearch)
self.txtSearch.setHidden(True)
self.hide_played_tracks = False
self.visible_playlist_tab: Callable[[], PlaylistTab] = \
self.tabPlaylist.currentWidget
self.load_last_playlists()
if Config.CARTS_HIDE:
self.cartsWidget.hide()
self.frame_6.hide()
else:
self.carts_init()
self.enable_play_next_controls()
self.timer.start(Config.TIMER_MS)
self.connect_signals_slots()
def cart_configure(self, cart: Carts, btn: CartButton) -> None:
"""Configure button with cart data"""
btn.setEnabled(False)
btn.pgb.setVisible(False)
if cart.path:
if helpers.file_is_readable(cart.path):
colour = Config.COLOUR_CART_READY
btn.path = cart.path
btn.player = self.music.VLC.media_player_new(cart.path)
btn.player.audio_set_volume(Config.VOLUME_VLC_DEFAULT)
if cart.enabled:
btn.setEnabled(True)
btn.pgb.setVisible(True)
else:
colour = Config.COLOUR_CART_ERROR
else:
colour = Config.COLOUR_CART_UNCONFIGURED
btn.setStyleSheet("background-color: " + colour + ";\n")
btn.setText(cart.name)
def cart_click(self) -> None:
"""Handle cart click"""
btn = self.sender()
if helpers.file_is_readable(btn.path):
# Don't allow clicks while we're playing
btn.setEnabled(False)
btn.player.play()
btn.is_playing = True
colour = Config.COLOUR_CART_PLAYING
thread = threading.Thread(target=self.cart_progressbar,
args=(btn,))
thread.start()
else:
colour = Config.COLOUR_CART_ERROR
btn.setStyleSheet("background-color: " + colour + ";\n")
btn.pgb.minimum = 0
def cart_edit(self, btn: CartButton, event: QEvent):
"""Handle context menu for cart button"""
with Session() as session:
cart = session.query(Carts).get(btn.cart_id)
if cart is None:
log.ERROR("cart_edit: cart not found")
return
dlg = CartDialog(parent=self, session=session, cart=cart)
if dlg.exec():
name = dlg.ui.lineEditName.text()
if not name:
QMessageBox.warning(self, "Error", "Name required")
return
path = dlg.path
if not path:
QMessageBox.warning(self, "Error", "Filename required")
return
if cart.path and helpers.file_is_readable(cart.path):
tags = helpers.get_tags(cart.path)
cart.duration = tags['duration']
cart.enabled = dlg.ui.chkEnabled.isChecked()
cart.name = name
cart.path = path
session.add(cart)
session.commit()
self.cart_configure(cart, btn)
def carts_init(self) -> None:
"""Initialse carts data structures"""
with Session() as session:
# Number carts from 1 for humanity
for cart_number in range(1, Config.CARTS_COUNT + 1):
cart = session.query(Carts).get(cart_number)
if cart is None:
cart = Carts(session, cart_number,
name=f"Cart #{cart_number}")
btn = CartButton(self, cart)
btn.clicked.connect(self.cart_click)
# Insert button on left of cart space starting at
# location zero
self.horizontalLayout_Carts.insertWidget(cart.id - 1, btn)
# Configure button
self.cart_configure(cart, btn)
def cart_progressbar(self, btn: CartButton) -> None:
"""Manage progress bar"""
ms = 0
btn.pgb.setMaximum(btn.duration)
while ms <= btn.duration:
btn.progress.emit(ms)
ms += 100
sleep(0.1)
def cart_tick(self) -> None:
"""Cart clock actions"""
for i in range(self.horizontalLayout_Carts.count()):
btn = self.horizontalLayout_Carts.itemAt(i).widget()
if not btn:
continue
if btn.is_playing:
if not btn.player.is_playing():
# Cart has finished playing
btn.is_playing = False
btn.setEnabled(True)
# Setting to position 0 doesn't seem to work
btn.player = self.music.VLC.media_player_new(btn.path)
btn.player.audio_set_volume(Config.VOLUME_VLC_DEFAULT)
colour = Config.COLOUR_CART_READY
btn.setStyleSheet("background-color: " + colour + ";\n")
btn.pgb.setValue(0)
def clear_selection(self) -> None:
""" Clear selected row"""
# Unselect any selected rows
if self.visible_playlist_tab():
self.visible_playlist_tab().clear_selection()
# Clear the search bar
self.search_playlist_clear()
def closeEvent(self, event: QEvent) -> None:
"""Handle attempt to close main window"""
# Don't allow window to close when a track is playing
if self.music.player and self.music.player.is_playing():
event.ignore()
helpers.show_warning(
"Track playing",
"Can't close application while track is playing")
else:
with Session() as session:
record = Settings.get_int_settings(
session, "mainwindow_height")
if record.f_int != self.height():
record.update(session, {'f_int': self.height()})
record = Settings.get_int_settings(session, "mainwindow_width")
if record.f_int != self.width():
record.update(session, {'f_int': self.width()})
record = Settings.get_int_settings(session, "mainwindow_x")
if record.f_int != self.x():
record.update(session, {'f_int': self.x()})
record = Settings.get_int_settings(session, "mainwindow_y")
if record.f_int != self.y():
record.update(session, {'f_int': self.y()})
# Save splitter settings
splitter_sizes = self.splitter.sizes()
assert len(splitter_sizes) == 2
splitter_top, splitter_bottom = splitter_sizes
record = Settings.get_int_settings(session, "splitter_top")
if record.f_int != splitter_top:
record.update(session, {'f_int': splitter_top})
record = Settings.get_int_settings(session, "splitter_bottom")
if record.f_int != splitter_bottom:
record.update(session, {'f_int': splitter_bottom})
# Save current tab
record = Settings.get_int_settings(session, "active_tab")
record.update(session,
{'f_int': self.tabPlaylist.currentIndex()})
event.accept()
def close_playlist_tab(self) -> None:
"""
Close active playlist tab, called by menu item
"""
self.close_tab(self.tabPlaylist.currentIndex())
def close_tab(self, tab_index: int) -> None:
"""
Close active playlist tab unless it holds the curren or next track.
Called from close_playlist_tab() or by clicking close button on tab.
"""
# Don't close current track playlist
if self.tabPlaylist.widget(tab_index) == (
self.current_track_playlist_tab):
self.statusbar.showMessage(
"Can't close current track playlist", 5000)
return
# Don't close next track playlist
if self.tabPlaylist.widget(tab_index) == self.next_track_playlist_tab:
self.statusbar.showMessage(
"Can't close next track playlist", 5000)
return
# Close playlist and remove tab
self.tabPlaylist.widget(tab_index).close()
self.tabPlaylist.removeTab(tab_index)
def connect_signals_slots(self) -> None:
self.action_About.triggered.connect(self.about)
self.action_Clear_selection.triggered.connect(self.clear_selection)
self.actionDebug.triggered.connect(self.debug)
self.actionClosePlaylist.triggered.connect(self.close_playlist_tab)
self.actionDownload_CSV_of_played_tracks.triggered.connect(
self.download_played_tracks)
self.actionEnable_controls.triggered.connect(
self.enable_play_next_controls)
self.actionExport_playlist.triggered.connect(self.export_playlist_tab)
self.actionFade.triggered.connect(self.fade)
self.actionFind_next.triggered.connect(
lambda: self.tabPlaylist.currentWidget().search_next())
self.actionFind_previous.triggered.connect(
lambda: self.tabPlaylist.currentWidget().search_previous())
self.actionImport.triggered.connect(self.import_track)
self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track)
self.actionMark_for_moving.triggered.connect(self.cut_rows)
self.actionMoveSelected.triggered.connect(self.move_selected)
self.actionNew_from_template.triggered.connect(self.new_from_template)
self.actionNewPlaylist.triggered.connect(self.create_and_show_playlist)
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
self.actionPaste.triggered.connect(self.paste_rows)
self.actionPlay_next.triggered.connect(self.play_next)
self.actionSave_as_template.triggered.connect(self.save_as_template)
self.actionSearch.triggered.connect(self.search_playlist)
self.actionSelect_next_track.triggered.connect(self.select_next_row)
self.actionSelect_previous_track.triggered.connect(
self.select_previous_row)
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
self.actionSetNext.triggered.connect(
lambda: self.tabPlaylist.currentWidget().set_selected_as_next())
self.actionSkipToNext.triggered.connect(self.play_next)
self.actionStop.triggered.connect(self.stop)
self.btnDrop3db.clicked.connect(self.drop3db)
self.btnFade.clicked.connect(self.fade)
self.btnHidePlayed.clicked.connect(self.hide_played)
self.btnStop.clicked.connect(self.stop)
self.hdrCurrentTrack.clicked.connect(self.show_current)
self.hdrNextTrack.clicked.connect(self.show_next)
self.tabPlaylist.currentChanged.connect(
lambda: self.tabPlaylist.currentWidget().tab_visible())
self.tabPlaylist.tabCloseRequested.connect(self.close_tab)
self.tabBar = self.tabPlaylist.tabBar()
self.tabBar.tabMoved.connect(self.move_tab)
self.txtSearch.returnPressed.connect(self.search_playlist_return)
self.timer.timeout.connect(self.tick)
def create_playlist(self,
session: Session,
playlist_name: Optional[str] = None) -> Playlists:
"""Create new playlist"""
if not playlist_name:
playlist_name = self.solicit_playlist_name()
if not playlist_name:
return
playlist = Playlists(session, playlist_name)
return playlist
def create_and_show_playlist(self) -> None:
"""Create new playlist and display it"""
with Session() as session:
playlist = self.create_playlist(session)
if playlist:
self.create_playlist_tab(session, playlist)
def create_playlist_tab(self, session: Session,
playlist: Playlists) -> int:
"""
Take the passed playlist database object, create a playlist tab and
add tab to display. Return index number of tab.
"""
playlist_tab = PlaylistTab(
musicmuster=self, session=session, playlist_id=playlist.id)
idx = self.tabPlaylist.addTab(playlist_tab, playlist.name)
self.tabPlaylist.setCurrentIndex(idx)
return idx
def cut_rows(self) -> None:
"""
Cut rows ready for pasting.
"""
with Session() as session:
# Save the selected PlaylistRows items ready for a later
# paste
self.selected_plrs = (
self.visible_playlist_tab().get_selected_playlistrows(session))
def debug(self):
"""Invoke debugger"""
import ipdb # type: ignore
ipdb.set_trace()
def disable_play_next_controls(self) -> None:
"""
Disable "play next" keyboard controls
"""
self.actionPlay_next.setEnabled(False)
self.statusbar.showMessage("Play controls: Disabled", 0)
def download_played_tracks(self) -> None:
"""Download a CSV of played tracks"""
dlg = DownloadCSV(self)
if dlg.exec():
start_dt = dlg.ui.dateTimeEdit.dateTime().toPyDateTime()
# Get output filename
pathspec = QFileDialog.getSaveFileName(
self, 'Save CSV of tracks played',
directory="/tmp/playlist.csv",
filter="CSV files (*.csv)"
)
if not pathspec:
return
path = pathspec[0]
if not path.endswith(".csv"):
path += ".csv"
with open(path, "w") as f:
with Session() as session:
for playdate in Playdates.played_after(session, start_dt):
f.write(
f"{playdate.track.artist},{playdate.track.title}\n"
)
def drop3db(self) -> None:
"""Drop music level by 3db if button checked"""
if self.btnDrop3db.isChecked():
self.music.set_volume(Config.VOLUME_VLC_DROP3db, set_default=False)
else:
self.music.set_volume(Config.VOLUME_VLC_DEFAULT, set_default=False)
def enable_play_next_controls(self) -> None:
"""
Enable "play next" keyboard controls
"""
self.actionPlay_next.setEnabled(True)
self.statusbar.showMessage("Play controls: Enabled", 0)
def end_of_track_actions(self) -> None:
"""
Clean up after track played
Actions required:
- Set flag to say we're not playing a track
- Reset current track
- Tell playlist_tab track has finished
- Reset current playlist_tab
- Reset clocks
- Reset end time
- Update headers
- Enable controls
"""
# Set flag to say we're not playing a track so that tick()
# doesn't see player=None and kick off end-of-track actions
self.playing = False
# Reset current track
if self.current_track:
self.previous_track = self.current_track
self.current_track = None
# Tell playlist_tab track has finished and
# reset current playlist_tab
if self.current_track_playlist_tab:
self.current_track_playlist_tab.play_stopped()
self.current_track_playlist_tab = None
# Reset clocks
self.frame_fade.setStyleSheet("")
self.frame_silent.setStyleSheet("")
self.label_elapsed_timer.setText("00:00")
self.label_end_timer.setText("00:00")
self.label_fade_timer.setText("00:00")
self.label_silent_timer.setText("00:00")
self.label_start_time.setText("00:00:00")
self.label_end_time.setText("00:00:00")
if self.next_track:
self.label_track_length.setText(
helpers.ms_to_mmss(self.next_track.duration)
)
self.label_fade_length.setText(helpers.ms_to_mmss(
self.next_track.silence_at - self.next_track.fade_at))
else:
self.label_track_length.setText("0:00")
self.label_fade_length.setText("0:00")
# Reset end time
self.current_track_end_time = None
# Update headers
self.update_headers()
# Enable controls
self.enable_play_next_controls()
def export_playlist_tab(self) -> None:
"""Export the current playlist to an m3u file"""
if not self.visible_playlist_tab():
return
playlist_id = self.visible_playlist_tab().playlist_id
with Session() as session:
# Get output filename
playlist = session.get(Playlists, playlist_id)
pathspec = QFileDialog.getSaveFileName(
self, 'Save Playlist',
directory=f"{playlist.name}.m3u",
filter="M3U files (*.m3u);;All files (*.*)"
)
if not pathspec:
return
path = pathspec[0]
if not path.endswith(".m3u"):
path += ".m3u"
# Get list of track rows for this playlist
plrs = PlaylistRows.get_rows_with_tracks(session, playlist_id)
with open(path, "w") as f:
# Required directive on first line
f.write("#EXTM3U\n")
for track in [a.track for a in plrs]:
f.write(
"#EXTINF:"
f"{int(track.duration / 1000)},"
f"{track.title} - "
f"{track.artist}"
"\n"
f"{track.path}"
"\n"
)
def fade(self) -> None:
"""Fade currently playing track"""
self.stop_playing(fade=True)
def about(self) -> None:
"""Get git tag and database name"""
try:
git_tag = str(
subprocess.check_output(
['git', 'describe'], stderr=subprocess.STDOUT
)
).strip('\'b\\n')
except subprocess.CalledProcessError as exc_info:
git_tag = str(exc_info.output)
with Session() as session:
dbname = session.bind.engine.url.database
QMessageBox.information(
self,
"About",
f"MusicMuster {git_tag}\n\nDatabase: {dbname}",
QMessageBox.Ok
)
def get_one_track(self, session: Session) -> Optional[Tracks]:
"""Show dialog box to select one track and return it to caller"""
dlg = DbDialog(self, session, get_one_track=True)
if dlg.exec():
return dlg.ui.track
def hide_played(self):
"""Toggle hide played tracks"""
if self.hide_played_tracks:
self.hide_played_tracks = False
self.btnHidePlayed.setText("Hide played")
else:
self.hide_played_tracks = True
self.btnHidePlayed.setText("Show played")
# Update all displayed playlists
with Session() as session:
for i in range(self.tabPlaylist.count()):
self.tabPlaylist.widget(i).update_display(session)
def import_track(self) -> None:
"""Import track file"""
dlg = QFileDialog()
dlg.setFileMode(QFileDialog.ExistingFiles)
dlg.setViewMode(QFileDialog.Detail)
dlg.setDirectory(Config.IMPORT_DESTINATION)
dlg.setNameFilter("Music files (*.flac *.mp3)")
if not dlg.exec_():
return
with Session() as session:
new_tracks = []
for fname in dlg.selectedFiles():
txt = ""
tags = helpers.get_tags(fname)
new_tracks.append((fname, tags))
title = tags['title']
artist = tags['artist']
count = 0
possible_matches = Tracks.search_titles(session, title)
if possible_matches:
txt += 'Similar to new track '
txt += f'"{title}" by "{artist} ({fname})":\n\n'
for track in possible_matches:
txt += f' "{track.title}" by {track.artist}'
txt += f' ({track.path})\n\n'
count += 1
if count >= Config.MAX_IMPORT_MATCHES:
txt += "\nThere are more similar-looking tracks"
break
txt += "\n"
# Check whether to proceed if there were potential matches
txt += "Proceed with import?"
result = QMessageBox.question(self,
"Possible duplicates",
txt,
QMessageBox.Ok,
QMessageBox.Cancel
)
if result == QMessageBox.Cancel:
return
# Import in separate thread
thread = threading.Thread(target=self._import_tracks,
args=(new_tracks,))
thread.start()
def _import_tracks(self, tracks: list):
"""
Create track objects from passed files and add to visible playlist
"""
with Session() as session:
for (fname, tags) in tracks:
track = Tracks(session, fname)
helpers.set_track_metadata(session, track)
helpers.normalise_track(track.path)
self.visible_playlist_tab().insert_track(session, track)
self.visible_playlist_tab().save_playlist(session)
def insert_header(self) -> None:
"""Show dialog box to enter header text and add to playlist"""
try:
playlist_tab = self.visible_playlist_tab()
except AttributeError:
# Just return if there's no visible playlist tab
return
# Get header text
dlg: QInputDialog = QInputDialog(self)
dlg.setInputMode(QInputDialog.TextInput)
dlg.setLabelText("Header text:")
dlg.resize(500, 100)
ok = dlg.exec()
if ok:
with Session() as session:
playlist_tab.insert_header(session, dlg.textValue())
playlist_tab.save_playlist(session)
def insert_track(self) -> None:
"""Show dialog box to select and add track from database"""
with Session() as session:
dlg = DbDialog(self, session, get_one_track=False)
dlg.exec()
def load_last_playlists(self) -> None:
"""Load the playlists that were open when the last session closed"""
with Session() as session:
for playlist in Playlists.get_open(session):
_ = self.create_playlist_tab(session, playlist)
# Set active tab
record = Settings.get_int_settings(session, "active_tab")
if record and record.f_int is not None:
self.tabPlaylist.setCurrentIndex(record.f_int)
def move_playlist_rows(self, session: Session,
playlistrows: List[PlaylistRows]) -> None:
"""
Move passed playlist rows to another playlist
Actions required:
- identify destination playlist
- update playlist for the rows in the database
- remove them from the display
- update destination playlist display if loaded
"""
if not playlistrows:
log.debug(f"musicmuster.move_playlist_rows({playlistrows=}")
# Identify destination playlist
visible_tab = self.visible_playlist_tab()
source_playlist = visible_tab.playlist_id
# Get destination playlist id
playlists = []
for playlist in Playlists.get_all(session):
if playlist.id == source_playlist:
continue
else:
playlists.append(playlist)
dlg = SelectPlaylistDialog(self, playlists=playlists, session=session)
dlg.exec()
if not dlg.playlist:
return
destination_playlist_id = dlg.playlist.id
# Remove moved rows from display and save
visible_tab.remove_rows([plr.row_number for plr in playlistrows])
visible_tab.save_playlist(session)
# Update destination playlist in the database
last_row = PlaylistRows.get_last_used_row(session,
destination_playlist_id)
if last_row is not None:
next_row = last_row + 1
else:
next_row = 0
for plr in playlistrows:
plr.row_number = next_row
plr.playlist_id = destination_playlist_id
# Reset played as it's not been played on this playlist
plr.played = False
# Update destination playlist_tab if visible (if not visible, it
# will be re-populated when it is opened)
destination_playlist_tab = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).playlist_id == dlg.playlist.id:
destination_playlist_tab = self.tabPlaylist.widget(tab)
break
if destination_playlist_tab:
destination_playlist_tab.populate_display(session, dlg.playlist.id)
def move_selected(self) -> None:
"""
Move selected rows to another playlist
"""
with Session() as session:
self.move_playlist_rows(
session,
self.visible_playlist_tab().get_selected_playlistrows(session)
)
def move_tab(self, frm: int, to: int) -> None:
"""Handle tabs being moved"""
with Session() as session:
Playlists.move_tab(session, frm, to)
def move_unplayed(self) -> None:
"""
Move unplayed rows to another playlist
"""
playlist_id = self.visible_playlist_tab().playlist_id
with Session() as session:
unplayed_playlist_rows = PlaylistRows.get_unplayed_rows(
session, playlist_id)
if helpers.ask_yes_no("Move tracks",
f"Move {len(unplayed_playlist_rows)} tracks:"
" Are you sure?"
):
self.move_playlist_rows(session, unplayed_playlist_rows)
def new_from_template(self) -> None:
"""Create new playlist from template"""
with Session() as session:
templates = Playlists.get_all_templates(session)
dlg = SelectPlaylistDialog(self, playlists=templates,
session=session)
dlg.exec()
template = dlg.playlist
if template:
playlist_name = self.solicit_playlist_name()
if not playlist_name:
return
playlist = Playlists.create_playlist_from_template(
session, template, playlist_name)
tab_index = self.create_playlist_tab(session, playlist)
playlist.mark_open(session, tab_index)
def open_playlist(self):
"""Open existing playlist"""
with Session() as session:
playlists = Playlists.get_closed(session)
dlg = SelectPlaylistDialog(self, playlists=playlists,
session=session)
dlg.exec()
playlist = dlg.playlist
if playlist:
tab_index = self.create_playlist_tab(session, playlist)
playlist.mark_open(session, tab_index)
def paste_rows(self) -> None:
"""
Paste earlier cut rows.
Process:
- ensure we have some cut rows
- if not pasting at end of playlist, move later rows down
- update plrs with correct playlist and row
- if moving between playlists: renumber source playlist rows
- else: check integrity of playlist rows
"""
if not self.selected_plrs:
return
playlist_tab = self.visible_playlist_tab()
dst_playlist_id = playlist_tab.playlist_id
dst_row = self.visible_playlist_tab().get_new_row_number()
with Session() as session:
# Create space in destination playlist
PlaylistRows.move_rows_down(session, dst_playlist_id,
dst_row, len(self.selected_plrs))
session.commit()
# Update plrs
row = dst_row
src_playlist_id = None
for plr in self.selected_plrs:
# Update moved rows
session.add(plr)
if not src_playlist_id:
src_playlist_id = plr.playlist_id
plr.playlist_id = dst_playlist_id
plr.row_number = row
row += 1
session.commit()
# Update display
self.visible_playlist_tab().populate_display(
session, dst_playlist_id, scroll_to_top=False)
# If source playlist is not destination playlist, fixup row
# numbers and update display
if src_playlist_id != dst_playlist_id:
PlaylistRows.fixup_rownumbers(session, src_playlist_id)
# Update source playlist_tab if visible (if not visible, it
# will be re-populated when it is opened)
source_playlist_tab = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).playlist_id == \
src_playlist_id:
source_playlist_tab = self.tabPlaylist.widget(tab)
break
if source_playlist_tab:
source_playlist_tab.populate_display(
session, src_playlist_id, scroll_to_top=False)
# Reset so rows can't be repasted
self.selected_plrs = None
def play_next(self) -> None:
"""
Play next track.
Actions required:
- If there is no next track set, return.
- If there's currently a track playing, fade it.
- Move next track to current track.
- Ensure playlist tabs are the correct colour
- Restore volume if -3dB active
- Play (new) current track.
- Tell database to record it as played
- Tell playlist track is now playing
- Note that track is now playing
- Disable play next controls
- Update headers
- Update clocks
"""
# If there is no next track set, return.
if not self.next_track:
log.debug("musicmuster.play_next(): no next track selected")
return
with Session() as session:
# If there's currently a track playing, fade it.
self.stop_playing(fade=True)
# Move next track to current track.
self.current_track = self.next_track
self.next_track = None
# Ensure playlist tabs are the correct colour
# If current track on different playlist_tab to last, reset
# last track playlist_tab colour
if self.current_track_playlist_tab != self.next_track_playlist_tab:
self.set_tab_colour(self.current_track_playlist_tab,
QColor(Config.COLOUR_NORMAL_TAB))
# # Update record of current track playlist_tab
self.current_track_playlist_tab = self.next_track_playlist_tab
self.next_track_playlist_tab = None
# Set current track playlist_tab colour
self.set_tab_colour(self.current_track_playlist_tab,
QColor(Config.COLOUR_CURRENT_TAB))
# Restore volume if -3dB active
if self.btnDrop3db.isChecked():
self.btnDrop3db.setChecked(False)
# Play (new) current track
start_at = datetime.now()
self.music.play(self.current_track.path)
# Tell database to record it as played
Playdates(session, self.current_track.id)
# Tell playlist track is now playing
self.current_track_playlist_tab.play_started(session)
# Note that track is now playing
self.playing = True
# Disable play next controls
self.disable_play_next_controls()
# Update headers
self.update_headers()
# Update clocks
self.label_track_length.setText(
helpers.ms_to_mmss(self.current_track.duration)
)
fade_at = self.current_track.fade_at
silence_at = self.current_track.silence_at
length = self.current_track.duration
self.label_fade_length.setText(
helpers.ms_to_mmss(silence_at - fade_at))
self.label_start_time.setText(
start_at.strftime(Config.TRACK_TIME_FORMAT))
self.current_track_end_time = start_at + timedelta(
milliseconds=self.current_track.duration)
self.label_end_time.setText(
self.current_track_end_time.strftime(Config.TRACK_TIME_FORMAT))
def save_as_template(self) -> None:
"""Save current playlist as template"""
with Session() as session:
template_names = [
a.name for a in Playlists.get_all_templates(session)
]
while True:
# Get name for new template
dlg = QInputDialog(self)
dlg.setInputMode(QInputDialog.TextInput)
dlg.setLabelText("Template name:")
dlg.resize(500, 100)
ok = dlg.exec()
if not ok:
return
template_name = dlg.textValue()
if template_name not in template_names:
break
helpers.show_warning("Duplicate template",
"Template name already in use"
)
Playlists.save_as_template(session,
self.visible_playlist_tab().playlist_id,
template_name)
helpers.show_OK("Template", "Template saved")
def search_playlist(self) -> None:
"""Show text box to search playlist"""
# Disable play controls so that 'return' in search box doesn't
# play next track
self.disable_play_next_controls()
self.txtSearch.setHidden(False)
self.txtSearch.setFocus()
# Select any text that may already be there
self.txtSearch.selectAll()
def search_playlist_clear(self) -> None:
"""Tidy up and reset search bar"""
# Clear the search text
self.visible_playlist_tab().set_search("")
# Clean up search bar
self.txtSearch.setText("")
self.txtSearch.setHidden(True)
def search_playlist_return(self) -> None:
"""Initiate search when return pressed"""
self.visible_playlist_tab().set_search(self.txtSearch.text())
self.enable_play_next_controls()
def select_next_row(self) -> None:
"""Select next or first row in playlist"""
self.visible_playlist_tab().select_next_row()
def select_previous_row(self) -> None:
"""Select previous or first row in playlist"""
self.visible_playlist_tab().select_previous_row()
def set_main_window_size(self) -> None:
"""Set size of window from database"""
with Session() as session:
record = Settings.get_int_settings(session, "mainwindow_x")
x = record.f_int or 1
record = Settings.get_int_settings(session, "mainwindow_y")
y = record.f_int or 1
record = Settings.get_int_settings(session, "mainwindow_width")
width = record.f_int or 1599
record = Settings.get_int_settings(session, "mainwindow_height")
height = record.f_int or 981
self.setGeometry(x, y, width, height)
record = Settings.get_int_settings(session, "splitter_top")
splitter_top = record.f_int or 256
record = Settings.get_int_settings(session, "splitter_bottom")
splitter_bottom = record.f_int or 256
self.splitter.setSizes([splitter_top, splitter_bottom])
return
def set_tab_colour(self, widget: PlaylistTab, colour: QColor) -> None:
"""
Find the tab containing the widget and set the text colour
"""
idx = self.tabPlaylist.indexOf(widget)
self.tabPlaylist.tabBar().setTabTextColor(idx, colour)
def show_current(self) -> None:
"""Scroll to show current track"""
log.debug(f"KAE: musicmuster.show_current()")
if self.current_track_playlist_tab != self.visible_playlist_tab():
self.tabPlaylist.setCurrentWidget(self.current_track_playlist_tab)
self.tabPlaylist.currentWidget().scroll_current_to_top()
def show_next(self) -> None:
"""Scroll to show next track"""
log.debug(f"KAE: musicmuster.show_next()")
if self.next_track_playlist_tab != self.visible_playlist_tab():
self.tabPlaylist.setCurrentWidget(self.next_track_playlist_tab)
self.tabPlaylist.currentWidget().scroll_next_to_top()
def solicit_playlist_name(self) -> Optional[str]:
"""Get name of playlist from user"""
dlg = QInputDialog(self)
dlg.setInputMode(QInputDialog.TextInput)
dlg.setLabelText("Playlist name:")
dlg.resize(500, 100)
ok = dlg.exec()
if ok:
return dlg.textValue()
else:
return None
def stop(self) -> None:
"""Stop playing immediately"""
self.stop_playing(fade=False)
def stop_playing(self, fade=True) -> None:
"""
Stop playing current track
Actions required:
- Return if not playing
- Stop/fade track
- Reset playlist_tab colour
- Run end-of-track actions
"""
# Return if not playing
if not self.playing:
return
# Stop/fade track
self.previous_track_position = self.music.get_position()
if fade:
self.music.fade()
else:
self.music.stop()
# Reset playlist_tab colour
if self.current_track_playlist_tab == self.next_track_playlist_tab:
self.set_tab_colour(self.current_track_playlist_tab,
QColor(Config.COLOUR_NEXT_TAB))
else:
self.set_tab_colour(self.current_track_playlist_tab,
QColor(Config.COLOUR_NORMAL_TAB))
# Run end-of-track actions
self.end_of_track_actions()
def this_is_the_next_track(self, session: Session,
playlist_tab: PlaylistTab,
track: Tracks) -> None:
"""
This is notification from a playlist tab that it holds the next
track to be played.
Actions required:
- Clear next track if on other tab
- Reset tab colour if on other tab
- Note next playlist tab
- Set next playlist_tab tab colour
- Note next track
- Update headers
- Populate info tabs
"""
# Clear next track if on another tab
if self.next_track_playlist_tab != playlist_tab:
# We need to reset the ex-next-track playlist
if self.next_track_playlist_tab:
self.next_track_playlist_tab.clear_next(session)
# Reset tab colour if on other tab
if (self.next_track_playlist_tab !=
self.current_track_playlist_tab):
self.set_tab_colour(
self.next_track_playlist_tab,
QColor(Config.COLOUR_NORMAL_TAB))
# Note next playlist tab
self.next_track_playlist_tab = playlist_tab
# Set next playlist_tab tab colour if it isn't the
# currently-playing tab
if (self.next_track_playlist_tab !=
self.current_track_playlist_tab):
self.set_tab_colour(
self.next_track_playlist_tab,
QColor(Config.COLOUR_NEXT_TAB))
# Note next track
self.next_track = TrackData(track)
# Populate footer if we're not currently playing
if not self.playing and self.next_track:
self.label_track_length.setText(
helpers.ms_to_mmss(self.next_track.duration)
)
self.label_fade_length.setText(helpers.ms_to_mmss(
self.next_track.silence_at - self.next_track.fade_at))
# Update headers
self.update_headers()
# Populate 'info' tabs with Wikipedia info, but queue it because
# it isn't quick
track_title = track.title
QTimer.singleShot(
1, lambda: self.tabInfolist.open_in_wikipedia(track_title)
)
def tick(self) -> None:
"""
Carry out clock tick actions.
The Time of Day clock and any cart progress bars are updated
every tick (500ms).
All other timers are updated every second. As the timer displays
have a one-second resolution, updating every 500ms can result in
some timers updating and then, 500ms later, other timers
updating. That looks odd.
Actions required:
- Update TOD clock
- Call cart_tick
- If track is playing:
update track clocks time and colours
- Else:
run stop_track
"""
# Update TOD clock
self.lblTOD.setText(datetime.now().strftime(Config.TOD_TIME_FORMAT))
# Update carts
self.cart_tick()
self.even_tick = not self.even_tick
if not self.even_tick:
return
if not self.playing:
return
# If track is playing, update track clocks time and colours
if self.music.player and self.music.player.is_playing():
playtime = self.music.get_playtime()
time_to_fade = (self.current_track.fade_at - playtime)
time_to_silence = (
self.current_track.silence_at - playtime)
time_to_end = (self.current_track.duration - playtime)
# Elapsed time
self.label_elapsed_timer.setText(helpers.ms_to_mmss(playtime))
# Time to fade
self.label_fade_timer.setText(helpers.ms_to_mmss(time_to_fade))
# If silent in the next 5 seconds, put warning colour on
# time to silence box and enable play controls
if time_to_silence <= 5500:
self.frame_silent.setStyleSheet(
f"background: {Config.COLOUR_ENDING_TIMER}"
)
self.enable_play_next_controls()
# Set warning colour on time to silence box when fade starts
elif time_to_fade <= 500:
self.frame_silent.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}"
)
# Five seconds before fade starts, set warning colour on
# time to silence box and enable play controls
elif time_to_fade <= 5500:
self.frame_fade.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}"
)
self.enable_play_next_controls()
else:
self.frame_silent.setStyleSheet("")
self.frame_fade.setStyleSheet("")
self.label_silent_timer.setText(
helpers.ms_to_mmss(time_to_silence)
)
# Time to end
self.label_end_timer.setText(helpers.ms_to_mmss(time_to_end))
else:
if self.playing:
self.stop_playing()
def update_current_track(self, track):
"""Update current track with passed details"""
self.current_track = TrackData(track)
self.update_headers()
def update_next_track(self, track):
"""Update next track with passed details"""
self.next_track = TrackData(track)
self.update_headers()
def update_headers(self) -> None:
"""
Update last / current / next track headers
"""
try:
self.hdrPreviousTrack.setText(
f"{self.previous_track.title} - {self.previous_track.artist}")
except AttributeError:
self.hdrPreviousTrack.setText("")
try:
self.hdrCurrentTrack.setText(
f"{self.current_track.title} - {self.current_track.artist}")
except AttributeError:
self.hdrCurrentTrack.setText("")
try:
self.hdrNextTrack.setText(
f"{self.next_track.title} - {self.next_track.artist}"
)
except AttributeError:
self.hdrNextTrack.setText("")
class CartDialog(QDialog):
"""Edit cart details"""
def __init__(self, parent: QMainWindow, session: Session,
cart: Carts) -> None:
"""
Manage carts
"""
super().__init__(parent)
self.parent = parent
self.session = session
self.ui = Ui_DialogCartEdit()
self.ui.setupUi(self)
self.path = cart.path
self.ui.lblPath.setText(self.path)
self.ui.lineEditName.setText(cart.name)
self.ui.chkEnabled.setChecked(cart.enabled)
self.ui.windowTitle = "Edit Cart " + str(cart.id)
self.ui.btnFile.clicked.connect(self.choose_file)
def choose_file(self) -> None:
"""File select"""
dlg = QFileDialog()
dlg.setFileMode(QFileDialog.ExistingFile)
dlg.setViewMode(QFileDialog.Detail)
dlg.setDirectory(Config.CART_DIRECTORY)
dlg.setNameFilter("Music files (*.flac *.mp3)")
if dlg.exec_():
self.path = dlg.selectedFiles()[0]
self.ui.lblPath.setText(self.path)
class DbDialog(QDialog):
"""Select track from database"""
def __init__(self, parent: QMainWindow, session: Session,
get_one_track: bool = False) -> None:
"""
Subclassed QDialog to manage track selection
If get_one_track is True, return after first track selection
with that track in ui.track. Otherwise, allow multiple tracks
to be added to the playlist.
"""
super().__init__(parent)
self.session = session
self.get_one_track = get_one_track
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.double_click)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.ui.track = None
if get_one_track:
self.ui.txtNote.hide()
self.ui.lblNote.hide()
record = Settings.get_int_settings(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
def __del__(self) -> None:
"""Save dialog size and position"""
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {'f_int': self.height()})
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {'f_int': self.width()})
def add_selected(self) -> None:
"""Handle Add button"""
if (not self.ui.matchList.selectedItems() and
not self.ui.txtNote.text()):
return
track = None
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.UserRole)
self.add_track(track)
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept()
def add_track(self, track: Tracks) -> None:
"""Add passed track to playlist on screen"""
if self.get_one_track:
self.ui.track = track
self.accept()
return
self.parent().visible_playlist_tab().insert_track(
self.session, track, note=self.ui.txtNote.text())
# Save to database (which will also commit changes)
self.parent().visible_playlist_tab().save_playlist(self.session)
# Clear note field and select search text to make it easier for
# next search
self.ui.txtNote.clear()
self.select_searchtext()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 1:
if self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, s)
else:
matches = Tracks.search_artists(self.session, s)
if matches:
for track in matches:
last_played = Playdates.last_played(self.session, track.id)
t = QListWidgetItem()
t.setText(
f"{track.title} - {track.artist} "
f"[{helpers.ms_to_mmss(track.duration)}] "
f"({helpers.get_relative_date(last_played)})"
)
t.setData(Qt.UserRole, track)
self.ui.matchList.addItem(t)
def double_click(self, entry: QListWidgetItem) -> None:
"""Add items that are double-clicked"""
track = entry.data(Qt.UserRole)
self.add_track(track)
# Select search text to make it easier for next search
self.select_searchtext()
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.UserRole)
self.ui.dbPath.setText(track.path)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())
class DownloadCSV(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.ui = Ui_DateSelect()
self.ui.setupUi(self)
self.ui.dateTimeEdit.setDate(QDate.currentDate())
self.ui.dateTimeEdit.setTime(QTime(19, 59, 0))
self.ui.buttonBox.accepted.connect(self.accept)
self.ui.buttonBox.rejected.connect(self.reject)
class SelectPlaylistDialog(QDialog):
def __init__(self, parent=None, playlists=None, session=None):
super().__init__(parent)
if playlists is None:
return
self.ui = Ui_dlgSelectPlaylist()
self.ui.setupUi(self)
self.ui.lstPlaylists.itemDoubleClicked.connect(self.list_doubleclick)
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(
self.session, "select_playlist_dialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(
self.session, "select_playlist_dialog_height")
height = record.f_int or 600
self.resize(width, height)
for playlist in playlists:
p = QListWidgetItem()
p.setText(playlist.name)
p.setData(Qt.UserRole, playlist)
self.ui.lstPlaylists.addItem(p)
def __del__(self): # review
record = Settings.get_int_settings(
self.session, "select_playlist_dialog_height")
if record.f_int != self.height():
record.update(self.session, {'f_int': self.height()})
record = Settings.get_int_settings(
self.session, "select_playlist_dialog_width")
if record.f_int != self.width():
record.update(self.session, {'f_int': self.width()})
def list_doubleclick(self, entry): # review
self.playlist = entry.data(Qt.UserRole)
self.accept()
def open(self): # review
if self.ui.lstPlaylists.selectedItems():
item = self.ui.lstPlaylists.currentItem()
self.playlist = item.data(Qt.UserRole)
self.accept()
if __name__ == "__main__":
"""
If command line arguments given, carry out requested function and
exit. Otherwise run full application.
"""
p = argparse.ArgumentParser()
# Only allow at most one option to be specified
group = p.add_mutually_exclusive_group()
group.add_argument('-b', '--bitrates',
action="store_true", dest="update_bitrates",
default=False, help="Update bitrates in database")
group.add_argument('-c', '--check-database',
action="store_true", dest="check_db",
default=False, help="Check and report on database")
args = p.parse_args()
# Run as required
if args.check_db:
log.debug("Updating database")
with Session() as session:
check_db(session)
elif args.update_bitrates:
log.debug("Update bitrates")
with Session() as session:
update_bitrates(session)
else:
# Normal run
try:
Base.metadata.create_all(engine)
app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec())
except Exception as exc:
from helpers import send_mail
msg = stackprinter.format(exc)
send_mail(Config.ERRORS_TO, Config.ERRORS_FROM,
"Exception from musicmuster", msg)
print("\033[1;31;47mUnhandled exception starts")
stackprinter.show(style="darkbg")
print("Unhandled exception ends\033[1;37;40m")