musicmuster/app/musicmuster.py
2021-05-28 11:33:24 +01:00

716 lines
23 KiB
Python
Executable File

#!/usr/bin/env python
import webbrowser
import os
import sys
import urllib.parse
from datetime import datetime, timedelta
from log import DEBUG, EXCEPTION
from slugify import slugify
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtWidgets import (
QApplication,
QDialog,
QFileDialog,
QInputDialog,
QListWidgetItem,
QMainWindow,
QMessageBox,
)
import helpers
import music
from config import Config
from model import Notes, Playdates, Playlists, PlaylistTracks, Settings, Tracks
from playlists import Playlist
from songdb import add_path_to_db
from ui.dlg_search_database_ui import Ui_Dialog
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist
from ui.main_window_ui import Ui_MainWindow
class Window(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.setupUi(self)
self.timer = QTimer()
self.even_tick = True
self.playing = False
self.connect_signals_slots()
self.disable_play_next_controls()
self.music = music.Music()
self.current_track = None
self.current_track_playlist = None
self.next_track = None
self.next_track_playlist = None
self.previous_track = None
self.previous_track_position = None
self.spnVolume.setValue(Config.VOLUME_VLC_DEFAULT)
self.menuTest.menuAction().setVisible(Config.TESTMODE)
self.set_main_window_size()
self.visible_playlist = self.tabPlaylist.currentWidget
self.load_last_playlists()
self.enable_play_next_controls()
self.timer.start(Config.TIMER_MS)
def add_file(self):
dlg = QFileDialog()
dlg.setFileMode(QFileDialog.ExistingFile)
dlg.setViewMode(QFileDialog.Detail)
dlg.setDirectory(Config.ROOT)
dlg.setNameFilter("Music files (*.flac *.mp3)")
if dlg.exec_():
for fname in dlg.selectedFiles():
track = add_path_to_db(fname)
self.visible_playlist()._add_to_playlist(track)
def set_main_window_size(self):
record = Settings.get_int("mainwindow_x")
x = record.f_int or 1
record = Settings.get_int("mainwindow_y")
y = record.f_int or 1
record = Settings.get_int("mainwindow_width")
width = record.f_int or 1599
record = Settings.get_int("mainwindow_height")
height = record.f_int or 981
self.setGeometry(x, y, width, height)
def clear_selection(self):
if self.visible_playlist():
self.visible_playlist().clearSelection()
def closeEvent(self, event):
"Don't allow window to close when a track is playing"
if self.music.playing():
DEBUG("closeEvent() ignored as music is playing")
event.ignore()
# TODO notify user
else:
DEBUG("closeEvent() accepted")
record = Settings.get_int("mainwindow_height")
if record.f_int != self.height():
record.update({'f_int': self.height()})
record = Settings.get_int("mainwindow_width")
if record.f_int != self.width():
record.update({'f_int': self.width()})
record = Settings.get_int("mainwindow_x")
if record.f_int != self.x():
record.update({'f_int': self.x()})
record = Settings.get_int("mainwindow_y")
if record.f_int != self.y():
record.update({'f_int': self.y()})
event.accept()
def connect_signals_slots(self):
self.actionAdd_file.triggered.connect(self.add_file)
self.action_Clear_selection.triggered.connect(self.clear_selection)
self.actionClosePlaylist.triggered.connect(self.close_playlist)
self.actionExport_playlist.triggered.connect(self.export_playlist)
self.actionFade.triggered.connect(self.fade)
self.actionMoveSelected.triggered.connect(self.move_selected)
self.actionNewPlaylist.triggered.connect(self.create_playlist)
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
self.actionPlay_next.triggered.connect(self.play_next)
self.actionSearch_database.triggered.connect(self.search_database)
self.actionSkip_next.triggered.connect(self.play_next)
self.actionSkipToEnd.triggered.connect(self.test_skip_to_end)
self.actionSkipToFade.triggered.connect(self.test_skip_to_fade)
self.actionTestFunction.triggered.connect(self.test_function)
self.btnAddFile.clicked.connect(self.add_file)
self.btnAddNote.clicked.connect(self.insert_note)
self.btnDatabase.clicked.connect(self.search_database)
self.btnFade.clicked.connect(self.fade)
self.btnPlay.clicked.connect(self.play_next)
self.btnSetNext.clicked.connect(self.set_next_track)
self.btnSongfacts.clicked.connect(self.songfacts_search)
self.btnStop.clicked.connect(self.stop)
self.btnWikipedia.clicked.connect(self.wikipedia_search)
self.spnVolume.valueChanged.connect(self.change_volume)
self.tabPlaylist.currentChanged.connect(self.tab_change)
self.timer.timeout.connect(self.tick)
def create_playlist(self):
"Create new playlist"
dlg = QInputDialog(self)
dlg.setInputMode(QInputDialog.TextInput)
dlg.setLabelText("Playlist name:")
dlg.resize(500, 100)
ok = dlg.exec()
if ok:
playlist = Playlists.new(dlg.textValue())
self.load_playlist(playlist)
def change_volume(self, volume):
"Change player maximum volume"
DEBUG(f"change_volume({volume})")
self.music.set_volume(volume)
def close_playlist(self):
self.visible_playlist().db.close()
index = self.tabPlaylist.currentIndex()
self.tabPlaylist.removeTab(index)
def create_note(self, text):
"""
Create note
If a row is selected, set note row to be rows above. Otherwise,
set note row to be end of playlist.
Return note.
"""
row = self.visible_playlist().get_selected_row()
if row is None:
row = self.visible_playlist().rowCount()
DEBUG(f"musicmuster.create_note(text={text}): row={row}")
return Notes.add_note(self.visible_playlist().db.id, row, text)
def disable_play_next_controls(self):
DEBUG("disable_play_next_controls()")
self.actionPlay_next.setEnabled(False)
def enable_play_next_controls(self):
DEBUG("enable_play_next_controls()")
self.actionPlay_next.setEnabled(True)
def export_playlist(self):
"Export the current playlist to an m3u file"
if not self.visible_playlist():
return
# Get output filename
pathspec = QFileDialog.getSaveFileName(
self, 'Save Playlist',
directory=f"{self.visible_playlist().db.name}.m3u",
filter="M3U files (*.m3u);;All files (*.*)"
)
if not pathspec:
return
path = pathspec[0]
if not path.endswith(".m3u"):
path += ".m3u"
with open(path, "w") as f:
# Required directive on first line
f.write("#EXTM3U\n")
for track in self.visible_playlist().db.get_tracks():
f.write(
"#EXTINF:"
f"{int(track.duration / 1000)},"
f"{track.title} - "
f"{track.artist}"
"\n"
f"{track.path}"
"\n"
)
def fade(self):
"Fade currently playing track"
if not self.current_track:
return
self.previous_track = self.current_track
self.previous_track_position = self.music.fade()
def insert_note(self):
"Add non-track row to playlist"
dlg = QInputDialog(self)
dlg.setInputMode(QInputDialog.TextInput)
dlg.setLabelText("Note:")
dlg.resize(500, 100)
ok = dlg.exec()
if ok:
note = self.create_note(dlg.textValue())
self.visible_playlist().add_note(note)
def load_last_playlists(self):
"Load the playlists that we loaded at end of last session"
for playlist in Playlists.get_last_used():
DEBUG(
f"load_last_playlists(), playlist.name={playlist.name}, "
f"playlist.id={playlist.id}")
self.load_playlist(playlist)
def load_playlist(self, playlist_db):
"""
Take the passed database object, create a playlist display, attach
the database object, get it populated and then add tab.
"""
playlist_table = Playlist()
playlist_table.db = playlist_db
playlist_table.populate()
idx = self.tabPlaylist.addTab(playlist_table, playlist_db.name)
self.tabPlaylist.setCurrentIndex(idx)
def move_selected(self):
"Move selected rows to another playlist"
playlists = list(
set(Playlists.get_all_playlists()) - {self.visible_playlist().db}
)
dlg = SelectPlaylistDialog(self, playlists=playlists)
dlg.exec()
if not dlg.plid:
return
# If destination playlist is visible, we need to add the moved
# tracks to it. If not, they will be automatically loaded when
# the playlistis opened.
destination_playlist = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).db.id == dlg.plid:
destination_playlist = self.tabPlaylist.widget(tab)
break
rows = []
for (row, track_id) in (
self.visible_playlist().get_selected_rows_and_tracks()):
rows.append(row)
# Update database
PlaylistTracks.move_track(
self.visible_playlist().db.id, row, dlg.plid)
# Update destination playlist if visible
if destination_playlist:
destination_playlist.add_track(Tracks.track_from_id(track_id))
# Update source playlist
self.visible_playlist().remove_rows(rows)
def play_next(self):
"""
Play next track.
If there is no next track set, return.
If there's currently a track playing, fade it.
Move next track to current track.
Play (new) current.
Update playlist "current track" metadata
Cue up next track in playlist if there is one.
Tell database to record it as played
Remember it was played for this session
Update metadata and headers, and repaint
"""
# If there is no next track set, return.
if not self.next_track:
return
DEBUG(
"play_next(), "
f"next_track={self.next_track.title if self.next_track else None} "
"current_track="
f"{self.current_track.title if self.current_track else None}"
)
# Stop current track, if any
self.stop_playing()
# Play next track
self.current_track = self.next_track
self.current_track_playlist = self.next_track_playlist
self.next_track = None
self.next_track_playlist = None
self.music.play(self.current_track.path)
# Update metadata
next_track_id = self.current_track_playlist.play_started()
if next_track_id is not None:
self.next_track = Tracks.get_track(next_track_id)
self.next_track_playlist = self.current_track_playlist
# Check we can read it
if not os.access(self.next_track.path, os.R_OK):
self.show_warning(
"Can't read next track",
self.next_track.path)
else:
self.next_track = self.next_track_playlist = None
# Tell database to record it as played
self.current_track.update_lastplayed()
Playdates.add_playdate(self.current_track)
self.disable_play_next_controls()
self.update_headers()
# Set time clocks
now = datetime.now()
self.label_start_tod.setText(now.strftime("%H:%M:%S"))
silence_at = self.current_track.silence_at
silence_time = now + timedelta(milliseconds=silence_at)
self.label_silent_tod.setText(silence_time.strftime("%H:%M:%S"))
self.label_fade_length.setText(helpers.ms_to_mmss(
silence_at - self.current_track.fade_at
))
def play_previous(self):
"Resume playing last track"
# TODO
pass
def search_database(self):
dlg = DbDialog(self)
dlg.exec()
def open_playlist(self):
playlists = Playlists.get_all_closed_playlists()
dlg = SelectPlaylistDialog(self, playlists=playlists)
dlg.exec()
if dlg.plid:
playlist = Playlists.open(dlg.plid)
self.load_playlist(playlist)
def set_next_track(self):
"Set selected track as next"
next_track_id = self.visible_playlist().set_selected_as_next()
if next_track_id:
if self.next_track_playlist != self.visible_playlist():
if self.next_track_playlist:
self.next_track_playlist.clear_next()
self.next_track_playlist = self.visible_playlist()
self.next_track = Tracks.get_track(next_track_id)
self.update_headers()
def show_warning(self, title, msg):
"Display a warning to user"
QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel)
def songfacts_search(self):
"Open a browser window in Songfacts searching for selected title"
title = self.visible_playlist().get_selected_title()
if title:
slug = slugify(title, replacements=([["'", ""]]))
url = f"https://www.songfacts.com/search/songs/{slug}"
webbrowser.open(url, new=2)
def stop(self):
"Stop playing immediately"
DEBUG("musicmuster.stop()")
self.stop_playing(fade=False)
def stop_playing(self, fade=True):
"Stop playing current track"
DEBUG("musicmuster.stop_playing()")
if not self.music.playing():
return
self.previous_track_position = self.music.get_position()
if fade:
self.music.fade()
else:
self.music.stop()
self.current_track_playlist.clear_current()
# Shuffle tracks along
self.previous_track = self.current_track
self.update_headers()
def tab_change(self):
"User has changed tabs"
pass
def test_function(self):
"Placeholder for test function"
import ipdb
ipdb.set_trace()
def test_skip_to_end(self):
"Skip current track to 1 second before silence"
if not self.playing():
return
self.music.set_position(self.get_current_silence_at() - 1000)
def test_skip_to_fade(self):
"Skip current track to 1 second before fade"
if not self.music.playing():
return
self.music.set_position(self.get_current_fade_at() - 1000)
def tick(self):
"""
Update screen
The Time of Day clock is updated every tick (500ms).
All other timers are updated every second. As the timers have a
one-second resolution, updating every 500ms can result in some
timers updating and then, 500ms later, other timers updating. That
looks odd.
"""
now = datetime.now()
self.lblTOD.setText(now.strftime("%H:%M:%S"))
self.even_tick = not self.even_tick
if not self.even_tick:
return
if self.music.playing():
self.playing = True
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
if time_to_end < 500:
self.label_elapsed_timer.setText(
helpers.ms_to_mmss(self.current_track.duration)
)
else:
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))
# Time to silence
if time_to_silence < 5000:
self.frame_silent.setStyleSheet(
f"background: {Config.COLOUR_ENDING_TIMER}"
)
self.enable_play_next_controls()
elif time_to_fade < 500:
self.frame_silent.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}"
)
self.enable_play_next_controls()
else:
self.frame_silent.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.label_end_timer.setText("00:00")
self.frame_silent.setStyleSheet("")
self.current_track_playlist.play_stopped()
self.playing = False
self.previous_track = self.current_track
self.current_track = None
self.current_track_playlist = None
self.previous_track_position = 0
self.update_headers()
def update_headers(self):
"Update last / current / next track headers"
try:
self.hdrPreviousTrack.setText(
f"{self.previous_track.title} - "
f"{self.previous_track.artist}"
)
except AttributeError:
self.hdrPreviousTrack.setText("")
try:
self.hdrCurrentTrack.setText(
f"{self.current_track.title} - "
f"{self.current_track.artist}"
)
except AttributeError:
self.hdrCurrentTrack.setText("")
try:
self.hdrNextTrack.setText(
f"{self.next_track.title} - "
f"{self.next_track.artist}"
)
except AttributeError:
self.hdrNextTrack.setText("")
def wikipedia_search(self):
"Open a browser window in Wikipedia searching for selected title"
title = self.visible_playlist().get_selected_title()
if title:
str = urllib.parse.quote_plus(title)
url = f"https://www.wikipedia.org/w/index.php?search={str}"
webbrowser.open(url, new=2)
class DbDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.ui.matchList.itemDoubleClicked.connect(self.double_click)
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.itemSelectionChanged.connect(self.selection_changed)
record = Settings.get_int("dbdialog_width")
width = record.f_int or 800
record = Settings.get_int("dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
def __del__(self):
record = Settings.get_int("dbdialog_height")
if record.f_int != self.height():
record.update({'f_int': self.height()})
record = Settings.get_int("dbdialog_width")
if record.f_int != self.width():
record.update({'f_int': self.width()})
def add_selected(self):
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track_id = item.data(Qt.UserRole)
self.add_track(track_id)
def add_selected_and_close(self):
self.add_selected()
self.close()
def chars_typed(self, s):
if len(s) >= 3:
matches = Tracks.search_titles(s)
self.ui.matchList.clear()
if matches:
for track in matches:
t = QListWidgetItem()
t.setText(
f"{track.title} - {track.artist} "
f"[{helpers.ms_to_mmss(track.duration)}]"
)
t.setData(Qt.UserRole, track.id)
self.ui.matchList.addItem(t)
def double_click(self, entry):
track_id = entry.data(Qt.UserRole)
self.add_track(track_id)
# Select search text to make it easier for next search
self.select_searchtext()
def add_track(self, track_id):
track = Tracks.track_from_id(track_id)
self.parent().visible_playlist()._add_to_playlist(track)
# Select search text to make it easier for next search
self.select_searchtext()
def select_searchtext(self):
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self):
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track_id = item.data(Qt.UserRole)
self.ui.dbPath.setText(Tracks.get_path(track_id))
class SelectPlaylistDialog(QDialog):
def __init__(self, parent=None, playlists=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.plid = None
record = Settings.get_int("select_playlist_dialog_width")
width = record.f_int or 800
record = Settings.get_int("select_playlist_dialog_height")
height = record.f_int or 600
self.resize(width, height)
for (plid, plname) in [(a.id, a.name) for a in playlists]:
p = QListWidgetItem()
p.setText(plname)
p.setData(Qt.UserRole, plid)
self.ui.lstPlaylists.addItem(p)
def __del__(self):
record = Settings.get_int("select_playlist_dialog_height")
if record.f_int != self.height():
record.update({'f_int': self.height()})
record = Settings.get_int("select_playlist_dialog_width")
if record.f_int != self.width():
record.update({'f_int': self.width()})
def list_doubleclick(self, entry):
self.plid = entry.data(Qt.UserRole)
self.accept()
def open(self):
if self.ui.lstPlaylists.selectedItems():
item = self.ui.lstPlaylists.currentItem()
self.plid = item.data(Qt.UserRole)
self.accept()
def main():
try:
app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec())
except Exception:
EXCEPTION("Unhandled Exception caught by musicmuster.main()")
if __name__ == "__main__":
main()