WIP: Can play tracks without errors

This commit is contained in:
Keith Edmunds 2025-03-21 12:10:46 +00:00
parent 7361086da5
commit a95aa918b1
13 changed files with 1134 additions and 1065 deletions

View File

@ -21,7 +21,6 @@ from PyQt6.QtWidgets import (
) )
# App imports # App imports
# from music_manager import FadeCurve
# Define singleton first as it's needed below # Define singleton first as it's needed below
@ -163,6 +162,14 @@ class TrackInfo(NamedTuple):
row_number: int row_number: int
# Classes for signals
@dataclass
class InsertTrack:
playlist_id: int
track_id: int | None
note: str
@singleton @singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
@ -181,9 +188,13 @@ class MusicMusterSignals(QObject):
search_wikipedia_signal = pyqtSignal(str) search_wikipedia_signal = pyqtSignal(str)
show_warning_signal = pyqtSignal(str, str) show_warning_signal = pyqtSignal(str, str)
signal_add_track_to_header = pyqtSignal(int, int) signal_add_track_to_header = pyqtSignal(int, int)
signal_insert_track = pyqtSignal(InsertTrack)
signal_playlist_selected_rows = pyqtSignal(int, list)
signal_set_next_row = pyqtSignal(int) signal_set_next_row = pyqtSignal(int)
# TODO: undestirable (and unresolvable) reference # signal_set_next_track takes a PlaylistRow as an argument. We can't
# signal_set_next_track = pyqtSignal(PlaylistRow) # specify that here as it requires us to import PlaylistRow from
# playlistrow.py, which itself imports MusicMusterSignals
signal_set_next_track = pyqtSignal(object)
span_cells_signal = pyqtSignal(int, int, int, int, int) span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int) status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal() track_ended_signal = pyqtSignal()

View File

@ -9,12 +9,22 @@ from PyQt6.QtWidgets import (
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
) )
from PyQt6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
)
# Third party imports # Third party imports
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
# App imports # App imports
from classes import MusicMusterSignals from classes import ApplicationError, InsertTrack, MusicMusterSignals
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
get_relative_date, get_relative_date,
@ -23,209 +33,153 @@ from helpers import (
from log import log from log import log
from models import Settings, Tracks from models import Settings, Tracks
from playlistmodel import PlaylistModel from playlistmodel import PlaylistModel
import repository
from ui import dlg_TrackSelect_ui from ui import dlg_TrackSelect_ui
class TrackSelectDialog(QDialog): class TrackInsertDialog(QDialog):
"""Select track from database"""
def __init__( def __init__(
self, self,
parent: QMainWindow, parent: QMainWindow,
session: Session, playlist_id: int,
new_row_number: int,
base_model: PlaylistModel,
add_to_header: Optional[bool] = False, add_to_header: Optional[bool] = False,
*args: Qt.WindowType,
**kwargs: Qt.WindowType,
) -> None: ) -> None:
""" """
Subclassed QDialog to manage track selection Subclassed QDialog to manage track selection
""" """
super().__init__(parent, *args, **kwargs) super().__init__(parent)
self.session = session self.playlist_id = playlist_id
self.new_row_number = new_row_number
self.base_model = base_model
self.add_to_header = add_to_header self.add_to_header = add_to_header
self.ui = dlg_TrackSelect_ui.Ui_Dialog() self.setWindowTitle("Insert Track")
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.add_selected)
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.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_setting(self.session, "dbdialog_width") # Title input on one line
width = record.f_int or 800 self.title_label = QLabel("Title:")
record = Settings.get_setting(self.session, "dbdialog_height") self.title_edit = QLineEdit()
height = record.f_int or 600 self.title_edit.textChanged.connect(self.update_list)
self.resize(width, height)
if add_to_header: title_layout = QHBoxLayout()
self.ui.lblNote.setVisible(False) title_layout.addWidget(self.title_label)
self.ui.txtNote.setVisible(False) title_layout.addWidget(self.title_edit)
def add_selected(self) -> None: # Track list
"""Handle Add button""" self.track_list = QListWidget()
self.track_list.itemDoubleClicked.connect(self.add_clicked)
self.track_list.itemSelectionChanged.connect(self.selection_changed)
track = None # Note input on one line
self.note_label = QLabel("Note:")
self.note_edit = QLineEdit()
if self.ui.matchList.selectedItems(): note_layout = QHBoxLayout()
item = self.ui.matchList.currentItem() note_layout.addWidget(self.note_label)
if item: note_layout.addWidget(self.note_edit)
track = item.data(Qt.ItemDataRole.UserRole)
note = self.ui.txtNote.text() # Track path
self.path = QLabel()
path_layout = QHBoxLayout()
path_layout.addWidget(self.path)
if not (track or note): # Buttons
self.add_btn = QPushButton("Add")
self.add_close_btn = QPushButton("Add and close")
self.close_btn = QPushButton("Close")
self.add_btn.clicked.connect(self.add_clicked)
self.add_close_btn.clicked.connect(self.add_and_close_clicked)
self.close_btn.clicked.connect(self.close)
btn_layout = QHBoxLayout()
btn_layout.addWidget(self.add_btn)
btn_layout.addWidget(self.add_close_btn)
btn_layout.addWidget(self.close_btn)
# Main layout
layout = QVBoxLayout()
layout.addLayout(title_layout)
layout.addWidget(self.track_list)
layout.addLayout(note_layout)
layout.addLayout(path_layout)
layout.addLayout(btn_layout)
self.setLayout(layout)
self.resize(800, 600)
# TODO
# record = Settings.get_setting(self.session, "dbdialog_width")
# width = record.f_int or 800
# record = Settings.get_setting(self.session, "dbdialog_height")
# height = record.f_int or 600
# self.resize(width, height)
def update_list(self, text: str) -> None:
self.track_list.clear()
if text.strip() == "":
# Do not search or populate list if input is empty
return return
track_id = None if text.startswith("a/") and len(text) > 2:
if track: self.tracks = repository.tracks_like_artist(text[2:])
track_id = track.id else:
self.tracks = repository.tracks_like_title(text)
if note and not track_id: for track in self.tracks:
self.base_model.insert_row(self.new_row_number, track_id, note) duration_str = ms_to_mmss(track.duration)
self.ui.txtNote.clear() last_played_str = get_relative_date(track.lastplayed)
self.new_row_number += 1 item_str = (
f"{track.title} - {track.artist} [{duration_str}] {last_played_str}"
)
item = QListWidgetItem(item_str)
item.setData(Qt.ItemDataRole.UserRole, track.track_id)
self.track_list.addItem(item)
def get_selected_track_id(self) -> int | None:
selected_items = self.track_list.selectedItems()
if selected_items:
return selected_items[0].data(Qt.ItemDataRole.UserRole)
return None
def add_clicked(self):
track_id = self.get_selected_track_id()
note_text = self.note_edit.text()
if track_id is None and not note_text:
return return
self.ui.txtNote.clear() insert_track_data = InsertTrack(self.playlist_id, track_id, note_text)
self.select_searchtext() self.signals.signal_insert_track.emit(insert_track_data)
if track_id is None: self.title_edit.clear()
log.error("track_id is None and should not be") self.note_edit.clear()
return self.track_list.clear()
self.title_edit.setFocus()
# Check whether track is already in playlist
move_existing = False
existing_prd = self.base_model.is_track_in_playlist(track_id)
if existing_prd is not None:
if ask_yes_no(
"Duplicate row",
"Track already in playlist. " "Move to new location?",
default_yes=True,
):
move_existing = True
if self.add_to_header: if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_to_header(
self.new_row_number, existing_prd, note
)
else:
self.base_model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header
self.accept()
else:
# Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.base_model.move_track_add_note(
self.new_row_number, existing_prd, note
)
else:
self.base_model.insert_row(self.new_row_number, track_id, note)
self.new_row_number += 1
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept() self.accept()
def chars_typed(self, s: str) -> None: def add_and_close_clicked(self):
"""Handle text typed in search box""" track_id = self.get_selected_track_id()
if track_id is not None:
self.ui.matchList.clear() note_text = self.note_edit.text()
if len(s) > 0: insert_track_data = InsertTrack(
if s.startswith("a/") and len(s) > 2: playlist_id=self.playlist_id, track_id=self.track_id, note=self.note
matches = Tracks.search_artists(self.session, "%" + s[2:])
elif 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 = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
) )
if last_playdate: insert_track_data = InsertTrack(self.playlist_id, track_id, note_text)
last_played = last_playdate.lastplayed self.signals.signal_insert_track.emit(insert_track_data)
t = QListWidgetItem() self.accept()
track_text = (
f"{track.title} - {track.artist} "
f"[{ms_to_mmss(track.duration)}] "
f"({get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_setting(self.session, "dbdialog_height")
record.f_int = self.height()
record = Settings.get_setting(self.session, "dbdialog_width")
record.f_int = self.width()
self.session.commit()
event.accept()
def keyPressEvent(self, event: QKeyEvent | None) -> None:
"""
Clear selection on ESC if there is one
"""
if event and event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None: def selection_changed(self) -> None:
"""Display selected track path in dialog box""" """Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems(): self.path.setText("")
track_id = self.get_selected_track_id()
if track_id is None:
return return
item = self.ui.matchList.currentItem() tracklist = [t for t in self.tracks if t.track_id == track_id]
track = item.data(Qt.ItemDataRole.UserRole) if not tracklist:
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None) return
if last_playdate: if len(tracklist) > 1:
last_played = last_playdate.lastplayed raise ApplicationError("More than one track returned")
else: track = tracklist[0]
last_played = None
path_text = f"{track.path} ({get_relative_date(last_played)})"
self.ui.dbPath.setText(path_text) self.path.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())

View File

@ -41,7 +41,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, Tracks from models import db, Tracks
from music_manager import track_sequence from playlistrow import TrackSequence
from playlistmodel import PlaylistModel from playlistmodel import PlaylistModel
import helpers import helpers
@ -701,6 +701,7 @@ class PickMatch(QDialog):
self.setWindowTitle("New or replace") self.setWindowTitle("New or replace")
layout = QVBoxLayout() layout = QVBoxLayout()
track_sequence = TrackSequence()
# Add instructions # Add instructions
instructions = ( instructions = (

View File

@ -168,7 +168,7 @@ def get_name(prompt: str, default: str = "") -> str | None:
def get_relative_date( def get_relative_date(
past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None past_date: Optional[dt.datetime], now: Optional[dt.datetime] = None
) -> str: ) -> str:
""" """
Return how long before reference_date past_date is as string. Return how long before reference_date past_date is as string.
@ -182,31 +182,33 @@ def get_relative_date(
if not past_date or past_date == Config.EPOCH: if not past_date or past_date == Config.EPOCH:
return "Never" return "Never"
if not reference_date: if not now:
reference_date = dt.datetime.now() now = dt.datetime.now()
# Check parameters # Check parameters
if past_date > reference_date: if past_date > now:
return "get_relative_date() past_date is after relative_date" raise ApplicationError("get_relative_date() past_date is after relative_date")
days: int delta = now - past_date
days_str: str days = delta.days
weeks: int
weeks_str: str
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7) if days == 0:
if weeks == days == 0: return "(Today)"
# Same day so return time instead elif days == 1:
return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M") return "(Yesterday)"
if weeks == 1:
weeks_str = "week" years, days_remain = divmod(days, 365)
else: months, days_final = divmod(days_remain, 30)
weeks_str = "weeks"
if days == 1: parts = []
days_str = "day" if years:
else: parts.append(f"{years}y")
days_str = "days" if months:
return f"{weeks} {weeks_str}, {days} {days_str}" parts.append(f"{months}m")
if days_final:
parts.append(f"{days_final}d")
formatted = " ".join(parts)
return f"({formatted} ago)"
def get_tags(path: str) -> Tags: def get_tags(path: str) -> Tags:
@ -264,39 +266,15 @@ def leading_silence(
return min(trim_ms, len(audio_segment)) return min(trim_ms, len(audio_segment))
def ms_to_mmss( def ms_to_mmss(ms: int | None, none: str = "-") -> str:
ms: Optional[int],
decimals: int = 0,
negative: bool = False,
none: Optional[str] = None,
) -> str:
"""Convert milliseconds to mm:ss""" """Convert milliseconds to mm:ss"""
minutes: int if ms is None:
remainder: int
seconds: float
if not ms:
if none:
return none return none
else:
return "-"
sign = ""
if ms < 0:
if negative:
sign = "-"
else:
ms = 0
minutes, remainder = divmod(ms, 60 * 1000) minutes, seconds = divmod(ms // 1000, 60)
seconds = remainder / 1000
# if seconds >= 59.5, it will be represented as 60, which looks odd. return f"{minutes}:{seconds:02d}"
# So, fake it under those circumstances
if seconds >= 59.5:
seconds = 59.0
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
def normalise_track(path: str) -> None: def normalise_track(path: str) -> None:

View File

@ -3,32 +3,23 @@ from __future__ import annotations
import datetime as dt import datetime as dt
from time import sleep from time import sleep
from typing import Any, Optional
# Third party imports # Third party imports
# import line_profiler # import line_profiler
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm.session import Session
import vlc # type: ignore import vlc # type: ignore
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject,
QThread, QThread,
) )
from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
# App imports # App imports
from classes import ApplicationError, MusicMusterSignals from classes import singleton
from config import Config from config import Config
import helpers import helpers
from log import log from log import log
from repository import PlaylistRowDTO
from vlcmanager import VLCManager
# Define the VLC callback function type # Define the VLC callback function type
# import ctypes # import ctypes
@ -63,106 +54,6 @@ from vlcmanager import VLCManager
# libc.vsnprintf.restype = ctypes.c_int # libc.vsnprintf.restype = ctypes.c_int
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
plr: PlaylistRow,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.plr = plr
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.plr.fade_graph = fc
self.finished.emit()
class FadeCurve:
GraphWidget: Optional[PlotWidget] = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.curve: Optional[PlotDataItem] = None
self.region: Optional[LinearRegionItem] = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QThread): class _FadeTrack(QThread):
finished = pyqtSignal() finished = pyqtSignal()
@ -196,21 +87,32 @@ class _FadeTrack(QThread):
self.finished.emit() self.finished.emit()
# TODO can we move this into the _Music class? @singleton
vlc_instance = VLCManager().vlc_instance class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
def __init__(self) -> None:
self.vlc_instance = vlc.Instance()
def get_instance(self) -> vlc.Instance:
return self.vlc_instance
class _Music: class Music:
""" """
Manage the playing of music tracks Manage the playing of music tracks
""" """
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
vlc_instance.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name self.name = name
vlc_manager = VLCManager()
self.vlc_instance = vlc_manager.get_instance()
self.vlc_instance.set_user_agent(name, name)
self.player: vlc.MediaPlayer | None = None
self.max_volume: int = Config.VLC_VOLUME_DEFAULT self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None self.start_dt: dt.datetime | None = None
# Set up logging # Set up logging
# self._set_vlc_log() # self._set_vlc_log()
@ -300,7 +202,7 @@ class _Music:
self, self,
path: str, path: str,
start_time: dt.datetime, start_time: dt.datetime,
position: Optional[float] = None, position: float | None = None,
) -> None: ) -> None:
""" """
Start playing the track at path. Start playing the track at path.
@ -317,7 +219,7 @@ class _Music:
log.error(f"play({path}): path not readable") log.error(f"play({path}): path not readable")
return None return None
self.player = vlc.MediaPlayer(vlc_instance, path) self.player = vlc.MediaPlayer(self.vlc_instance, path)
if self.player is None: if self.player is None:
log.error(f"_Music:play: failed to create MediaPlayer ({path=})") log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
helpers.show_warning( helpers.show_warning(
@ -341,7 +243,7 @@ class _Music:
self.player.set_position(position) self.player.set_position(position)
def set_volume( def set_volume(
self, volume: Optional[int] = None, set_default: bool = True self, volume: int | None = None, set_default: bool = True
) -> None: ) -> None:
"""Set maximum volume used for player""" """Set maximum volume used for player"""
@ -381,370 +283,3 @@ class _Music:
self.player.stop() self.player.stop()
self.player.release() self.player.release()
self.player = None self.player = None
class PlaylistRow:
"""
Object to manage playlist row and track.
"""
def __init__(self, dto: PlaylistRowDTO) -> None:
"""
The dto object will include a Tracks object if this row has a track.
"""
self.dto = dto
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
self.end_of_track_signalled: bool = False
self.end_time: dt.datetime | None = None
self.fade_graph: Any | None = None
self.fade_graph_start_updates: dt.datetime | None = None
self.forecast_end_time: dt.datetime | None = None
self.forecast_start_time: dt.datetime | None = None
self.note_bg: str | None = None
self.note_fg: str | None = None
self.resume_marker: float = 0.0
self.row_bg: str | None = None
self.row_fg: str | None = None
self.start_time: dt.datetime | None = None
def __repr__(self) -> str:
return (
f"<PlaylistRow(playlist_id={self.dto.playlist_id}, "
f"row_number={self.dto.row_number}, "
f"playlistrow_id={self.dto.playlistrow_id}, "
f"note={self.dto.note}, track_id={self.dto.track_id}>"
)
# Expose TrackDTO fields as properties
@property
def artist(self):
return self.dto.artist
@property
def bitrate(self):
return self.dto.bitrate
@property
def duration(self):
return self.dto.duration
@property
def fade_at(self):
return self.dto.fade_at
@property
def intro(self):
return self.dto.intro
@property
def lastplayed(self):
return self.dto.lastplayed
@property
def path(self):
return self.dto.path
@property
def silence_at(self):
return self.dto.silence_at
@property
def start_gap(self):
return self.dto.start_gap
@property
def title(self):
return self.dto.title
@property
def track_id(self):
return self.dto.track_id
@track_id.setter
def track_id(self, value: int) -> None:
"""
Adding a track_id should only happen to a header row.
"""
if self.track_id:
raise ApplicationError("Attempting to add track to row with existing track ({self=}")
# TODO: set up write access to track_id. Should only update if
# track_id == 0. Need to update all other track fields at the
# same time.
print("set track_id attribute for {self=}, {value=}")
pass
# Expose PlaylistRowDTO fields as properties
@property
def note(self):
return self.dto.note
@note.setter
def note(self, value: str) -> None:
# TODO set up write access to db
print("set note attribute for {self=}, {value=}")
# self.dto.note = value
@property
def played(self):
return self.dto.played
@played.setter
def played(self, value: bool = True) -> None:
# TODO set up write access to db
print("set played attribute for {self=}")
# self.dto.played = value
@property
def playlist_id(self):
return self.dto.playlist_id
@property
def playlistrow_id(self):
return self.dto.playlistrow_id
@property
def row_number(self):
return self.dto.row_number
@row_number.setter
def row_number(self, value: int) -> None:
# TODO do we need to set up write access to db?
self.dto.row_number = value
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def drop3db(self, enable: bool) -> None:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
) -> Optional[dt.datetime]:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration())
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
def update_playlist_and_row(self, session: Session) -> None:
"""
Update local playlist_id and row_number from playlistrow_id
"""
# TODO: only seems to be used by track_sequence
return
# plr = session.get(PlaylistRows, self.playlistrow_id)
# if not plr:
# raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
# self.playlist_id = plr.playlist_id
# self.row_number = plr.row_number
class TrackSequence:
next: Optional[PlaylistRow] = None
current: Optional[PlaylistRow] = None
previous: Optional[PlaylistRow] = None
def set_next(self, rat: Optional[PlaylistRow]) -> None:
"""
Set the 'next' track to be passed rat. Clear
any previous next track. If passed rat is None
just clear existing next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if rat is None:
self.next = None
else:
self.next = rat
self.create_fade_graph()
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
if self.next is None:
raise ApplicationError("hell in a handcart")
self.worker = _AddFadeCurve(
self.next,
track_path=self.next.path,
track_fade_at=self.next.fade_at,
track_silence_at=self.next.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
track_sequence = TrackSequence()

View File

@ -70,12 +70,12 @@ from classes import (
TrackInfo, TrackInfo,
) )
from config import Config from config import Config
from dialogs import TrackSelectDialog from dialogs import TrackInsertDialog
from file_importer import FileImporter from file_importer import FileImporter
from helpers import ask_yes_no, file_is_unreadable, get_name from helpers import ask_yes_no, file_is_unreadable, get_name
from log import log from log import log
from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks
from music_manager import PlaylistRow, track_sequence from playlistrow import PlaylistRow, TrackSequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
from querylistmodel import QuerylistModel from querylistmodel import QuerylistModel
@ -94,12 +94,12 @@ class Current:
base_model: PlaylistModel base_model: PlaylistModel
proxy_model: PlaylistProxyModel proxy_model: PlaylistProxyModel
playlist_id: int = 0 playlist_id: int = 0
selected_rows: list[int] = [] selected_row_numbers: list[int] = []
def __repr__(self): def __repr__(self):
return ( return (
f"<Current(base_model={self.base_model}, proxy_model={self.proxy_model}, " f"<Current(base_model={self.base_model}, proxy_model={self.proxy_model}, "
f"playlist_id={self.playlist_id}, selected_rows={self.selected_rows}>" f"playlist_id={self.playlist_id}, selected_rows={self.selected_row_numbers}>"
) )
@ -1194,6 +1194,7 @@ class Window(QMainWindow):
self.catch_return_key = False self.catch_return_key = False
self.importer: Optional[FileImporter] = None self.importer: Optional[FileImporter] = None
self.current = Current() self.current = Current()
self.track_sequence = TrackSequence()
webbrowser.register( webbrowser.register(
"browser", "browser",
@ -1217,7 +1218,7 @@ class Window(QMainWindow):
return return
# Don't allow window to close when a track is playing # Don't allow window to close when a track is playing
if track_sequence.current and track_sequence.current.is_playing(): if self.track_sequence.current and self.track_sequence.current.is_playing():
event.ignore() event.ignore()
helpers.show_warning( helpers.show_warning(
self, "Track playing", "Can't close application while track is playing" self, "Track playing", "Can't close application while track is playing"
@ -1671,7 +1672,7 @@ class Window(QMainWindow):
Clear next track Clear next track
""" """
track_sequence.set_next(None) self.track_sequence.set_next(None)
self.update_headers() self.update_headers()
def clear_selection(self) -> None: def clear_selection(self) -> None:
@ -1704,8 +1705,8 @@ class Window(QMainWindow):
).playlist_id ).playlist_id
# Don't close current track playlist # Don't close current track playlist
if track_sequence.current is not None: if self.track_sequence.current is not None:
current_track_playlist_id = track_sequence.current.playlist_id current_track_playlist_id = self.track_sequence.current.playlist_id
if current_track_playlist_id: if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id: if closing_tab_playlist_id == current_track_playlist_id:
helpers.show_OK( helpers.show_OK(
@ -1714,8 +1715,8 @@ class Window(QMainWindow):
return False return False
# Don't close next track playlist # Don't close next track playlist
if track_sequence.next is not None: if self.track_sequence.next is not None:
next_track_playlist_id = track_sequence.next.playlist_id next_track_playlist_id = self.track_sequence.next.playlist_id
if next_track_playlist_id: if next_track_playlist_id:
if closing_tab_playlist_id == next_track_playlist_id: if closing_tab_playlist_id == next_track_playlist_id:
helpers.show_OK( helpers.show_OK(
@ -1777,8 +1778,8 @@ class Window(QMainWindow):
of the playlist. of the playlist.
""" """
if self.current.selected_rows: if self.current.selected_row_numbers:
return self.current.selected_rows[0] return self.current.selected_row_numbers[0]
return self.current.base_model.rowCount() return self.current.base_model.rowCount()
def debug(self): def debug(self):
@ -1816,8 +1817,8 @@ class Window(QMainWindow):
def drop3db(self) -> None: def drop3db(self) -> None:
"""Drop music level by 3db if button checked""" """Drop music level by 3db if button checked"""
if track_sequence.current: if self.track_sequence.current:
track_sequence.current.drop3db(self.footer_section.btnDrop3db.isChecked()) self.track_sequence.current.drop3db(self.footer_section.btnDrop3db.isChecked())
def enable_escape(self, enabled: bool) -> None: def enable_escape(self, enabled: bool) -> None:
""" """
@ -1843,13 +1844,8 @@ class Window(QMainWindow):
- Enable controls - Enable controls
""" """
if track_sequence.current: if self.track_sequence.current:
# Dereference the fade curve so it can be garbage collected self.track_sequence.move_current_to_previous()
track_sequence.current.fade_graph = None
# Reset track_sequence objects
track_sequence.previous = track_sequence.current
track_sequence.current = None
# Tell playlist previous track has finished # Tell playlist previous track has finished
self.current.base_model.previous_track_ended() self.current.base_model.previous_track_ended()
@ -1915,8 +1911,8 @@ class Window(QMainWindow):
def fade(self) -> None: def fade(self) -> None:
"""Fade currently playing track""" """Fade currently playing track"""
if track_sequence.current: if self.track_sequence.current:
track_sequence.current.fade() self.track_sequence.current.fade()
def get_tab_index_for_playlist(self, playlist_id: int) -> Optional[int]: def get_tab_index_for_playlist(self, playlist_id: int) -> Optional[int]:
""" """
@ -1976,15 +1972,11 @@ class Window(QMainWindow):
def insert_track(self) -> None: def insert_track(self) -> None:
"""Show dialog box to select and add track from database""" """Show dialog box to select and add track from database"""
with db.Session() as session: dlg = TrackInsertDialog(
dlg = TrackSelectDialog(
parent=self, parent=self,
session=session, playlist_id=self.active_tab().playlist_id
new_row_number=self.current_row_or_end(),
base_model=self.current.base_model,
) )
dlg.exec() dlg.exec()
session.commit()
def load_last_playlists(self) -> None: def load_last_playlists(self) -> None:
"""Load the playlists that were open when the last session closed""" """Load the playlists that were open when the last session closed"""
@ -2038,7 +2030,7 @@ class Window(QMainWindow):
# Save the selected PlaylistRows items ready for a later # Save the selected PlaylistRows items ready for a later
# paste # paste
self.move_source_rows = self.current.selected_rows self.move_source_rows = self.current.selected_row_numbers
self.move_source_model = self.current.base_model self.move_source_model = self.current.base_model
log.debug( log.debug(
@ -2080,21 +2072,14 @@ class Window(QMainWindow):
) )
# Reset track_sequences # Reset track_sequences
with db.Session() as session: self.track_sequence.update()
for ts in [
track_sequence.next,
track_sequence.current,
track_sequence.previous,
]:
if ts:
ts.update_playlist_and_row(session)
def move_selected(self) -> None: def move_selected(self) -> None:
""" """
Move selected rows to another playlist Move selected rows to another playlist
""" """
selected_rows = self.current.selected_rows selected_rows = self.current.selected_row_numbers
if not selected_rows: if not selected_rows:
return return
@ -2147,9 +2132,9 @@ class Window(QMainWindow):
# that moved row the next track # that moved row the next track
set_next_row: Optional[int] = None set_next_row: Optional[int] = None
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.current.playlist_id == to_playlist_model.playlist_id and self.track_sequence.current.playlist_id == to_playlist_model.playlist_id
and destination_row == track_sequence.current.row_number + 1 and destination_row == self.track_sequence.current.row_number + 1
): ):
set_next_row = destination_row set_next_row = destination_row
@ -2185,7 +2170,7 @@ class Window(QMainWindow):
""" """
# If there is no next track set, return. # If there is no next track set, return.
if track_sequence.next is None: if self.track_sequence.next is None:
log.error("musicmuster.play_next(): no next track selected") log.error("musicmuster.play_next(): no next track selected")
return return
@ -2202,35 +2187,34 @@ class Window(QMainWindow):
log.debug("issue223: play_next: 10ms timer disabled") log.debug("issue223: play_next: 10ms timer disabled")
# If there's currently a track playing, fade it. # If there's currently a track playing, fade it.
if track_sequence.current: if self.track_sequence.current:
track_sequence.current.fade() self.track_sequence.current.fade()
# Move next track to current track. # Move next track to current track.
# end_of_track_actions() will have saved current track to # end_of_track_actions() will have saved current track to
# previous_track # previous_track
track_sequence.current = track_sequence.next self.track_sequence.move_next_to_current()
if self.track_sequence.current is None:
# Clear next track raise ApplicationError("No current track")
self.clear_next()
# Restore volume if -3dB active # Restore volume if -3dB active
if self.footer_section.btnDrop3db.isChecked(): if self.footer_section.btnDrop3db.isChecked():
self.footer_section.btnDrop3db.setChecked(False) self.footer_section.btnDrop3db.setChecked(False)
# Play (new) current track # Play (new) current track
log.debug(f"Play: {track_sequence.current.title}") log.debug(f"Play: {self.track_sequence.current.title}")
track_sequence.current.play(position) self.track_sequence.current.play(position)
# Update clocks now, don't wait for next tick # Update clocks now, don't wait for next tick
self.update_clocks() self.update_clocks()
# Show closing volume graph # Show closing volume graph
if track_sequence.current.fade_graph: if self.track_sequence.current.fade_graph:
track_sequence.current.fade_graph.GraphWidget = ( self.track_sequence.current.fade_graph.GraphWidget = (
self.footer_section.widgetFadeVolume self.footer_section.widgetFadeVolume
) )
track_sequence.current.fade_graph.clear() self.track_sequence.current.fade_graph.clear()
track_sequence.current.fade_graph.plot() self.track_sequence.current.fade_graph.plot()
# Disable play next controls # Disable play next controls
self.catch_return_key = True self.catch_return_key = True
@ -2263,10 +2247,10 @@ class Window(QMainWindow):
track_info = self.active_tab().get_selected_row_track_info() track_info = self.active_tab().get_selected_row_track_info()
if not track_info: if not track_info:
# Otherwise get track_id to next track to play # Otherwise get track_id to next track to play
if track_sequence.next: if self.track_sequence.next:
if track_sequence.next.track_id: if self.track_sequence.next.track_id:
track_info = TrackInfo( track_info = TrackInfo(
track_sequence.next.track_id, track_sequence.next.row_number self.track_sequence.next.track_id, self.track_sequence.next.row_number
) )
else: else:
return return
@ -2384,12 +2368,12 @@ class Window(QMainWindow):
Return True if it has, False if not Return True if it has, False if not
""" """
if track_sequence.current and self.catch_return_key: if self.track_sequence.current and self.catch_return_key:
# Suppress inadvertent double press # Suppress inadvertent double press
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.current.start_time and self.track_sequence.current.start_time
and track_sequence.current.start_time and self.track_sequence.current.start_time
+ dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS) + dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS)
> dt.datetime.now() > dt.datetime.now()
): ):
@ -2398,8 +2382,8 @@ class Window(QMainWindow):
# If return is pressed during first PLAY_NEXT_GUARD_MS then # If return is pressed during first PLAY_NEXT_GUARD_MS then
# default to NOT playing the next track, else default to # default to NOT playing the next track, else default to
# playing it. # playing it.
default_yes: bool = track_sequence.current.start_time is not None and ( default_yes: bool = self.track_sequence.current.start_time is not None and (
(dt.datetime.now() - track_sequence.current.start_time).total_seconds() (dt.datetime.now() - self.track_sequence.current.start_time).total_seconds()
* 1000 * 1000
> Config.PLAY_NEXT_GUARD_MS > Config.PLAY_NEXT_GUARD_MS
) )
@ -2428,18 +2412,18 @@ class Window(QMainWindow):
- If a track is playing, make that the next track - If a track is playing, make that the next track
""" """
if not track_sequence.previous: if not self.track_sequence.previous:
return return
# Return if no saved position # Return if no saved position
resume_marker = track_sequence.previous.resume_marker resume_marker = self.track_sequence.previous.resume_marker
if not resume_marker: if not resume_marker:
log.error("No previous track position") log.error("No previous track position")
return return
# We want to use play_next() to resume, so copy the previous # We want to use play_next() to resume, so copy the previous
# track to the next track: # track to the next track:
track_sequence.set_next(track_sequence.previous) self.track_sequence.move_previous_to_next()
# Now resume playing the now-next track # Now resume playing the now-next track
self.play_next(resume_marker) self.play_next(resume_marker)
@ -2448,15 +2432,15 @@ class Window(QMainWindow):
# We need to fake the start time to reflect where we resumed the # We need to fake the start time to reflect where we resumed the
# track # track
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.current.start_time and self.track_sequence.current.start_time
and track_sequence.current.duration and self.track_sequence.current.duration
and track_sequence.current.resume_marker and self.track_sequence.current.resume_marker
): ):
elapsed_ms = ( elapsed_ms = (
track_sequence.current.duration * track_sequence.current.resume_marker self.track_sequence.current.duration * self.track_sequence.current.resume_marker
) )
track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms) self.track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms)
def search_playlist(self) -> None: def search_playlist(self) -> None:
"""Show text box to search playlist""" """Show text box to search playlist"""
@ -2491,12 +2475,12 @@ class Window(QMainWindow):
row_number: Optional[int] = None row_number: Optional[int] = None
if self.current.selected_rows: if self.current.selected_row_numbers:
row_number = self.current.selected_rows[0] row_number = self.current.selected_row_numbers[0]
if row_number is None: if row_number is None:
if track_sequence.next: if self.track_sequence.next:
if track_sequence.next.track_id: if self.track_sequence.next.track_id:
row_number = track_sequence.next.row_number row_number = self.track_sequence.next.row_number
if row_number is None: if row_number is None:
return None return None
@ -2540,8 +2524,8 @@ class Window(QMainWindow):
def show_current(self) -> None: def show_current(self) -> None:
"""Scroll to show current track""" """Scroll to show current track"""
if track_sequence.current: if self.track_sequence.current:
self.show_track(track_sequence.current) self.show_track(self.track_sequence.current)
def show_warning(self, title: str, body: str) -> None: def show_warning(self, title: str, body: str) -> None:
""" """
@ -2554,8 +2538,8 @@ class Window(QMainWindow):
def show_next(self) -> None: def show_next(self) -> None:
"""Scroll to show next track""" """Scroll to show next track"""
if track_sequence.next: if self.track_sequence.next:
self.show_track(track_sequence.next) self.show_track(self.track_sequence.next)
def show_status_message(self, message: str, timing: int) -> None: def show_status_message(self, message: str, timing: int) -> None:
""" """
@ -2594,8 +2578,8 @@ class Window(QMainWindow):
"""Stop playing immediately""" """Stop playing immediately"""
self.stop_autoplay = True self.stop_autoplay = True
if track_sequence.current: if self.track_sequence.current:
track_sequence.current.stop() self.track_sequence.current.stop()
def tab_change(self) -> None: def tab_change(self) -> None:
"""Called when active tab changed""" """Called when active tab changed"""
@ -2607,22 +2591,22 @@ class Window(QMainWindow):
Called every 10ms Called every 10ms
""" """
if track_sequence.current: if self.track_sequence.current:
track_sequence.current.update_fade_graph() self.track_sequence.current.update_fade_graph()
def tick_100ms(self) -> None: def tick_100ms(self) -> None:
""" """
Called every 100ms Called every 100ms
""" """
if track_sequence.current: if self.track_sequence.current:
try: try:
track_sequence.current.check_for_end_of_track() self.track_sequence.current.check_for_end_of_track()
# Update intro counter if applicable and, if updated, return # Update intro counter if applicable and, if updated, return
# because playing an intro takes precedence over timing a # because playing an intro takes precedence over timing a
# preview. # preview.
intro_ms_remaining = track_sequence.current.time_remaining_intro() intro_ms_remaining = self.track_sequence.current.time_remaining_intro()
if intro_ms_remaining > 0: if intro_ms_remaining > 0:
self.footer_section.label_intro_timer.setText( self.footer_section.label_intro_timer.setText(
f"{intro_ms_remaining / 1000:.1f}" f"{intro_ms_remaining / 1000:.1f}"
@ -2682,17 +2666,17 @@ class Window(QMainWindow):
""" """
# If track is playing, update track clocks time and colours # If track is playing, update track clocks time and colours
if track_sequence.current and track_sequence.current.is_playing(): if self.track_sequence.current and self.track_sequence.current.is_playing():
# Elapsed time # Elapsed time
self.header_section.label_elapsed_timer.setText( self.header_section.label_elapsed_timer.setText(
helpers.ms_to_mmss(track_sequence.current.time_playing()) helpers.ms_to_mmss(self.track_sequence.current.time_playing())
+ " / " + " / "
+ helpers.ms_to_mmss(track_sequence.current.duration) + helpers.ms_to_mmss(self.track_sequence.current.duration)
) )
# Time to fade # Time to fade
time_to_fade = track_sequence.current.time_to_fade() time_to_fade = self.track_sequence.current.time_to_fade()
time_to_silence = track_sequence.current.time_to_silence() time_to_silence = self.track_sequence.current.time_to_silence()
self.footer_section.label_fade_timer.setText( self.footer_section.label_fade_timer.setText(
helpers.ms_to_mmss(time_to_fade) helpers.ms_to_mmss(time_to_fade)
) )
@ -2741,25 +2725,25 @@ class Window(QMainWindow):
Update last / current / next track headers Update last / current / next track headers
""" """
if track_sequence.previous: if self.track_sequence.previous:
self.header_section.hdrPreviousTrack.setText( self.header_section.hdrPreviousTrack.setText(
f"{track_sequence.previous.title} - {track_sequence.previous.artist}" f"{self.track_sequence.previous.title} - {self.track_sequence.previous.artist}"
) )
else: else:
self.header_section.hdrPreviousTrack.setText("") self.header_section.hdrPreviousTrack.setText("")
if track_sequence.current: if self.track_sequence.current:
self.header_section.hdrCurrentTrack.setText( self.header_section.hdrCurrentTrack.setText(
f"{track_sequence.current.title.replace('&', '&&')} - " f"{self.track_sequence.current.title.replace('&', '&&')} - "
f"{track_sequence.current.artist.replace('&', '&&')}" f"{self.track_sequence.current.artist.replace('&', '&&')}"
) )
else: else:
self.header_section.hdrCurrentTrack.setText("") self.header_section.hdrCurrentTrack.setText("")
if track_sequence.next: if self.track_sequence.next:
self.header_section.hdrNextTrack.setText( self.header_section.hdrNextTrack.setText(
f"{track_sequence.next.title.replace('&', '&&')} - " f"{self.track_sequence.next.title.replace('&', '&&')} - "
f"{track_sequence.next.artist.replace('&', '&&')}" f"{self.track_sequence.next.artist.replace('&', '&&')}"
) )
else: else:
self.header_section.hdrNextTrack.setText("") self.header_section.hdrNextTrack.setText("")
@ -2774,25 +2758,25 @@ class Window(QMainWindow):
# Do we need to set a 'next' icon? # Do we need to set a 'next' icon?
set_next = True set_next = True
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.next and self.track_sequence.next
and track_sequence.current.playlist_id == track_sequence.next.playlist_id and self.track_sequence.current.playlist_id == self.track_sequence.next.playlist_id
): ):
set_next = False set_next = False
for idx in range(self.tabBar.count()): for idx in range(self.tabBar.count()):
widget = self.playlist_section.tabPlaylist.widget(idx) widget = self.playlist_section.tabPlaylist.widget(idx)
if ( if (
track_sequence.next self.track_sequence.next
and set_next and set_next
and widget.playlist_id == track_sequence.next.playlist_id and widget.playlist_id == self.track_sequence.next.playlist_id
): ):
self.playlist_section.tabPlaylist.setTabIcon( self.playlist_section.tabPlaylist.setTabIcon(
idx, QIcon(Config.PLAYLIST_ICON_NEXT) idx, QIcon(Config.PLAYLIST_ICON_NEXT)
) )
elif ( elif (
track_sequence.current self.track_sequence.current
and widget.playlist_id == track_sequence.current.playlist_id and widget.playlist_id == self.track_sequence.current.playlist_id
): ):
self.playlist_section.tabPlaylist.setTabIcon( self.playlist_section.tabPlaylist.setTabIcon(
idx, QIcon(Config.PLAYLIST_ICON_CURRENT) idx, QIcon(Config.PLAYLIST_ICON_CURRENT)

View File

@ -48,7 +48,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, NoteColours, Playdates, PlaylistRows, Tracks from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from music_manager import PlaylistRow, track_sequence from playlistrow import PlaylistRow, TrackSequence
import repository import repository
@ -83,6 +83,7 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_id = playlist_id self.playlist_id = playlist_id
self.is_template = is_template self.is_template = is_template
self.track_sequence = TrackSequence()
self.playlist_rows: dict[int, PlaylistRow] = {} self.playlist_rows: dict[int, PlaylistRow] = {}
self.selected_rows: list[PlaylistRow] = [] self.selected_rows: list[PlaylistRow] = []
@ -93,13 +94,16 @@ class PlaylistModel(QAbstractTableModel):
self.signals.begin_reset_model_signal.connect(self.begin_reset_model) self.signals.begin_reset_model_signal.connect(self.begin_reset_model)
self.signals.end_reset_model_signal.connect(self.end_reset_model) self.signals.end_reset_model_signal.connect(self.end_reset_model)
self.signals.signal_add_track_to_header.connect(self.add_track_to_header) self.signals.signal_add_track_to_header.connect(self.add_track_to_header)
self.signals.signal_playlist_selected_rows.connect(self.set_selected_rows)
self.signals.signal_set_next_row.connect(self.set_next_row)
with db.Session() as session: with db.Session() as session:
# Ensure row numbers in playlist are contiguous # Ensure row numbers in playlist are contiguous
# TODO: remove this # TODO: remove this
PlaylistRows.fixup_rownumbers(session, playlist_id) PlaylistRows.fixup_rownumbers(session, playlist_id)
# Populate self.playlist_rows # Populate self.playlist_rows
self.load_data(session) self.load_data()
self.update_track_times() self.update_track_times()
def __repr__(self) -> str: def __repr__(self) -> str:
@ -131,7 +135,7 @@ class PlaylistModel(QAbstractTableModel):
# playing it. It's also possible that the track marked as # playing it. It's also possible that the track marked as
# next has already been played. Check for either of those. # next has already been played. Check for either of those.
for ts in [track_sequence.next, track_sequence.current]: for ts in [self.track_sequence.next, self.track_sequence.current]:
if ( if (
ts ts
and ts.row_number == row_number and ts.row_number == row_number
@ -178,14 +182,14 @@ class PlaylistModel(QAbstractTableModel):
# Update local copy # Update local copy
self.refresh_row(selected_row.row_number) self.refresh_row(selected_row.row_number)
# Repaint row # Repaint row
roles = [ roles_to_invalidate = [
Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.FontRole, Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole, Qt.ItemDataRole.ForegroundRole,
] ]
# only invalidate required roles # only invalidate required roles
self.invalidate_row(row_number, roles) self.invalidate_row(selected_row.row_number, roles_to_invalidate)
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
@ -207,16 +211,16 @@ class PlaylistModel(QAbstractTableModel):
return QBrush(QColor(Config.COLOUR_UNREADABLE)) return QBrush(QColor(Config.COLOUR_UNREADABLE))
# Current track # Current track
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.current.playlist_id == self.playlist_id and self.track_sequence.current.playlist_id == self.playlist_id
and track_sequence.current.row_number == row and self.track_sequence.current.row_number == row
): ):
return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST)) return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST))
# Next track # Next track
if ( if (
track_sequence.next self.track_sequence.next
and track_sequence.next.playlist_id == self.playlist_id and self.track_sequence.next.playlist_id == self.playlist_id
and track_sequence.next.row_number == row and self.track_sequence.next.row_number == row
): ):
return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST)) return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST))
@ -271,52 +275,55 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: current_track_started()") log.debug(f"{self}: current_track_started()")
if not track_sequence.current: if not self.track_sequence.current:
return return
row_number = track_sequence.current.row_number row_number = self.track_sequence.current.row_number
playlist_dto = self.playlist_rows[row_number]
# Check for OBS scene change # Check for OBS scene change
self.obs_scene_change(row_number) self.obs_scene_change(row_number)
# Sanity check that we have a track_id # Sanity check that we have a track_id
track_id = track_sequence.current.track_id track_id = playlist_dto.track_id
if not track_id: if not track_id:
raise ApplicationError( raise ApplicationError(
f"{self}: current_track_started() called with {track_id=}" f"current_track_started() called with no track_id ({playlist_dto=})"
) )
with db.Session() as session: # TODO: ensure Playdates is updated
# Update Playdates in database # with db.Session() as session:
log.debug(f"{self}: update playdates {track_id=}") # # Update Playdates in database
Playdates(session, track_id) # log.debug(f"{self}: update playdates {track_id=}")
session.commit() # Playdates(session, track_id)
# session.commit()
# Mark track as played in playlist # Mark track as played in playlist
log.debug(f"{self}: Mark track as played") playlist_dto.played = True
plr = session.get(PlaylistRows, track_sequence.current.playlistrow_id)
if plr:
plr.played = True
self.refresh_row(session, plr.row_number)
else:
log.error(
f"{self}: Can't retrieve plr, {track_sequence.current.playlistrow_id=}"
)
# Update colour and times for current row # Update colour and times for current row
# only invalidate required roles roles_to_invalidate = [Qt.ItemDataRole.DisplayRole]
roles = [Qt.ItemDataRole.DisplayRole] self.invalidate_row(row_number, roles_to_invalidate)
self.invalidate_row(row_number, roles)
# Update previous row in case we're hiding played rows # Update previous row in case we're hiding played rows
if track_sequence.previous and track_sequence.previous.row_number: if self.track_sequence.previous and self.track_sequence.previous.row_number:
# only invalidate required roles # only invalidate required roles
self.invalidate_row(track_sequence.previous.row_number, roles) self.invalidate_row(self.track_sequence.previous.row_number, roles_to_invalidate)
# Update all other track times # Update all other track times
self.update_track_times() self.update_track_times()
# Find next track # Find next track
next_row = self.find_next_row_to_play(row_number)
if next_row:
self.signals.signal_set_next_track.emit(self.playlist_rows[next_row])
def find_next_row_to_play(self, from_row_number: int) -> int | None:
"""
Find the next row to play in this playlist. Return row number or
None if there's no next track.
"""
next_row = None next_row = None
unplayed_rows = [ unplayed_rows = [
a a
@ -326,11 +333,11 @@ class PlaylistModel(QAbstractTableModel):
] ]
if unplayed_rows: if unplayed_rows:
try: try:
next_row = min([a for a in unplayed_rows if a > row_number]) next_row = min([a for a in unplayed_rows if a > from_row_number])
except ValueError: except ValueError:
next_row = min(unplayed_rows) next_row = min(unplayed_rows)
if next_row is not None:
self.set_next_row(next_row) return next_row
def data( def data(
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
@ -401,7 +408,7 @@ class PlaylistModel(QAbstractTableModel):
session.commit() session.commit()
super().endRemoveRows() super().endRemoveRows()
self.reset_track_sequence_row_numbers() self.track_sequence.update()
self.update_track_times() self.update_track_times()
def _display_role(self, row: int, column: int, rat: PlaylistRow) -> str: def _display_role(self, row: int, column: int, rat: PlaylistRow) -> str:
@ -477,7 +484,8 @@ class PlaylistModel(QAbstractTableModel):
with db.Session() as session: with db.Session() as session:
self.refresh_data(session) self.refresh_data(session)
super().endResetModel() super().endResetModel()
self.reset_track_sequence_row_numbers() self.track_sequence.update()
self.update_track_times()
def _edit_role(self, row: int, column: int, rat: PlaylistRow) -> str | int: def _edit_role(self, row: int, column: int, rat: PlaylistRow) -> str | int:
""" """
@ -742,16 +750,17 @@ class PlaylistModel(QAbstractTableModel):
note=note, note=note,
) )
# Insert into self.playlist_rows # Move rows down to make room
for destination_row in range(len(self.playlist_rows), new_row_number, -1): for destination_row in range(len(self.playlist_rows), new_row_number, -1):
self.playlist_rows[destination_row] = self.playlist_rows[destination_row - 1] self.playlist_rows[destination_row] = self.playlist_rows[destination_row - 1]
self.playlist_rows[new_row_number] = new_row # Insert into self.playlist_rows
self.playlist_rows[new_row_number] = PlaylistRow(new_row)
super().endInsertRows() super().endInsertRows()
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
# TODO check this what we want to do and how we want to do it self.track_sequence.update()
self.reset_track_sequence_row_numbers() self.update_track_times()
roles_to_invalidate = [ roles_to_invalidate = [
Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.DisplayRole,
@ -816,7 +825,7 @@ class PlaylistModel(QAbstractTableModel):
return None return None
def load_data(self, session: Session) -> None: def load_data(self) -> None:
""" """
Same as refresh data, but only used when creating playslit. Same as refresh data, but only used when creating playslit.
Distinguishes profile time between initial load and other Distinguishes profile time between initial load and other
@ -843,8 +852,8 @@ class PlaylistModel(QAbstractTableModel):
# build a new playlist_rows # build a new playlist_rows
# shouldn't be PlaylistRow # shouldn't be PlaylistRow
new_playlist_rows: dict[int, PlaylistRow] = {} new_playlist_rows: dict[int, PlaylistRow] = {}
for p in repository.get_playlist_rows(self.playlist_id): for dto in repository.get_playlist_rows(self.playlist_id):
new_playlist_rows[p.row_number] = PlaylistRow(p) new_playlist_rows[dto.row_number] = PlaylistRow(dto)
# Copy to self.playlist_rows # Copy to self.playlist_rows
self.playlist_rows = new_playlist_rows self.playlist_rows = new_playlist_rows
@ -854,16 +863,9 @@ class PlaylistModel(QAbstractTableModel):
Mark row as unplayed Mark row as unplayed
""" """
with db.Session() as session:
for row_number in row_numbers: for row_number in row_numbers:
playlist_row = session.get( self.playlist_rows[row_number].played = False
PlaylistRows, self.playlist_rows[row_number].playlistrow_id self.refresh_row(row_number)
)
if not playlist_row:
return
playlist_row.played = False
session.commit()
self.refresh_row(session, row_number)
self.update_track_times() self.update_track_times()
# only invalidate required roles # only invalidate required roles
@ -933,7 +935,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_data(session) self.refresh_data(session)
# Update display # Update display
self.reset_track_sequence_row_numbers() self.track_sequence.update()
self.update_track_times() self.update_track_times()
# only invalidate required roles # only invalidate required roles
roles = [ roles = [
@ -987,8 +989,8 @@ class PlaylistModel(QAbstractTableModel):
[self.playlist_rows[a].playlistrow_id for a in row_group], [self.playlist_rows[a].playlistrow_id for a in row_group],
): ):
if ( if (
track_sequence.current self.track_sequence.current
and playlist_row.id == track_sequence.current.playlistrow_id and playlist_row.id == self.track_sequence.current.playlistrow_id
): ):
# Don't move current track # Don't move current track
continue continue
@ -1004,35 +1006,32 @@ class PlaylistModel(QAbstractTableModel):
session.commit() session.commit()
# Reset of model must come after session has been closed # Reset of model must come after session has been closed
self.reset_track_sequence_row_numbers() self.track_sequence.update()
self.signals.end_reset_model_signal.emit(to_playlist_id) self.signals.end_reset_model_signal.emit(to_playlist_id)
self.update_track_times() self.update_track_times()
def move_track_add_note( def move_track_add_note(
self, new_row_number: int, existing_rat: PlaylistRow, note: str self, new_row_number: int, existing_plr: PlaylistRow, note: str
) -> None: ) -> None:
""" """
Move existing_rat track to new_row_number and append note to any existing note Move existing_rat track to new_row_number and append note to any existing note
""" """
log.debug( log.debug(
f"{self}: move_track_add_note({new_row_number=}, {existing_rat=}, {note=}" f"{self}: move_track_add_note({new_row_number=}, {existing_plr=}, {note=}"
) )
if note: if note:
with db.Session() as session: playlist_row = self.playlist_rows[existing_plr.row_number]
playlist_row = session.get(PlaylistRows, existing_rat.playlistrow_id)
if playlist_row:
if playlist_row.note: if playlist_row.note:
playlist_row.note += "\n" + note playlist_row.note += "\n" + note
else: else:
playlist_row.note = note playlist_row.note = note
self.refresh_row(session, playlist_row.row_number) self.refresh_row(existing_plr.row_number)
session.commit()
# Carry out the move outside of the session context to ensure # Carry out the move outside of the session context to ensure
# database updated with any note change # database updated with any note change
self.move_rows([existing_rat.row_number], new_row_number) self.move_rows([existing_plr.row_number], new_row_number)
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
def obs_scene_change(self, row_number: int) -> None: def obs_scene_change(self, row_number: int) -> None:
@ -1083,15 +1082,15 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: previous_track_ended()") log.debug(f"{self}: previous_track_ended()")
# Sanity check # Sanity check
if not track_sequence.previous: if not self.track_sequence.previous:
log.error( log.error(
f"{self}: playlistmodel:previous_track_ended called with no current track" f"{self}: playlistmodel:previous_track_ended called with no current track"
) )
return return
if track_sequence.previous.row_number is None: if self.track_sequence.previous.row_number is None:
log.error( log.error(
f"{self}: previous_track_ended called with no row number " f"{self}: previous_track_ended called with no row number "
f"({track_sequence.previous=})" f"({self.track_sequence.previous=})"
) )
return return
@ -1100,7 +1099,7 @@ class PlaylistModel(QAbstractTableModel):
roles = [ roles = [
Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.BackgroundRole,
] ]
self.invalidate_row(track_sequence.previous.row_number, roles) self.invalidate_row(self.track_sequence.previous.row_number, roles)
def refresh_data(self, session: Session) -> None: def refresh_data(self, session: Session) -> None:
""" """
@ -1123,21 +1122,25 @@ class PlaylistModel(QAbstractTableModel):
# build a new playlist_rows # build a new playlist_rows
new_playlist_rows: dict[int, PlaylistRow] = {} new_playlist_rows: dict[int, PlaylistRow] = {}
for p in repository.get_playlist_rows(self.playlist_id): for dto in repository.get_playlist_rows(self.playlist_id):
if p.playlistrow_id not in plrid_to_row: if dto.playlistrow_id not in plrid_to_row:
new_playlist_rows[p.row_number] = PlaylistRow(p) new_playlist_rows[dto.row_number] = PlaylistRow(dto)
else: else:
new_playlist_row = self.playlist_rows[plrid_to_row[p.playlistrow_id]] new_playlist_row = self.playlist_rows[plrid_to_row[dto.playlistrow_id]]
new_playlist_row.row_number = p.row_number new_playlist_row.row_number = dto.row_number
# Copy to self.playlist_rows # Copy to self.playlist_rows
self.playlist_rows = new_playlist_rows self.playlist_rows = new_playlist_rows
def refresh_row(self, session, row_number): def refresh_row(self, row_number: int) -> None:
"""Populate dict for one row from database""" """Populate dict for one row from database"""
p = PlaylistRows.deep_row(session, self.playlist_id, row_number) plrid = self.playlist_rows[row_number].playlistrow_id
self.playlist_rows[row_number] = PlaylistRow(p) refreshed_row = repository.get_playlist_row(plrid)
if not refreshed_row:
raise ApplicationError(f"Failed to retrieve row {self.playlist_id=}, {row_number=}")
self.playlist_rows[row_number] = PlaylistRow(refreshed_row)
def remove_track(self, row_number: int) -> None: def remove_track(self, row_number: int) -> None:
""" """
@ -1146,14 +1149,8 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: remove_track({row_number=})") log.debug(f"{self}: remove_track({row_number=})")
with db.Session() as session: self.playlist_rows[row_number].track_id = None
playlist_row = session.get(
PlaylistRows, self.playlist_rows[row_number].playlistrow_id
)
if playlist_row:
playlist_row.track_id = None
session.commit()
self.refresh_row(session, row_number)
# only invalidate required roles # only invalidate required roles
roles = [ roles = [
Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.DisplayRole,
@ -1170,7 +1167,7 @@ class PlaylistModel(QAbstractTableModel):
with db.Session() as session: with db.Session() as session:
track = session.get(Tracks, track_id) track = session.get(Tracks, track_id)
set_track_metadata(track) set_track_metadata(track)
self.refresh_row(session, row_number) self.refresh_row(row_number)
self.update_track_times() self.update_track_times()
roles = [ roles = [
Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.BackgroundRole,
@ -1181,32 +1178,6 @@ class PlaylistModel(QAbstractTableModel):
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
session.commit() session.commit()
def reset_track_sequence_row_numbers(self) -> None:
"""
Signal handler for when row ordering has changed.
Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will
be correctly updated with change of row number, but track_sequence.next will still
contain row_number==4. This function fixes up the track_sequence row numbers by
looking up the playlistrow_id and retrieving the row number from the database.
"""
log.debug(f"issue285: {self}: reset_track_sequence_row_numbers()")
# Check the track_sequence.next, current and previous plrs and
# update the row number
with db.Session() as session:
for ts in [
track_sequence.next,
track_sequence.current,
track_sequence.previous,
]:
if ts:
ts.update_playlist_and_row(session)
session.commit()
self.update_track_times()
def remove_comments(self, row_numbers: list[int]) -> None: def remove_comments(self, row_numbers: list[int]) -> None:
""" """
Remove comments from passed rows Remove comments from passed rows
@ -1349,16 +1320,16 @@ class PlaylistModel(QAbstractTableModel):
# calculate end time when all tracks are played. # calculate end time when all tracks are played.
end_time_str = "" end_time_str = ""
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.current.end_time and self.track_sequence.current.end_time
and ( and (
row_number row_number
< track_sequence.current.row_number < self.track_sequence.current.row_number
< rat.row_number < rat.row_number
) )
): ):
section_end_time = ( section_end_time = (
track_sequence.current.end_time self.track_sequence.current.end_time
+ dt.timedelta(milliseconds=duration) + dt.timedelta(milliseconds=duration)
) )
end_time_str = ( end_time_str = (
@ -1411,12 +1382,16 @@ class PlaylistModel(QAbstractTableModel):
return True return True
def set_selected_rows(self, selected_rows: list[int]) -> None: def set_selected_rows(self, playlist_id: int, selected_row_numbers: list[int]) -> None:
""" """
Keep track of which rows are selected in the view Handle signal_playlist_selected_rows to keep track of which rows
are selected in the view
""" """
self.selected_rows = [self.playlist_rows[a] for a in selected_rows] if playlist_id != self.playlist_id:
return
self.selected_rows = [self.playlist_rows[a] for a in selected_row_numbers]
def set_next_row(self, playlist_id: int) -> None: def set_next_row(self, playlist_id: int) -> None:
""" """
@ -1430,8 +1405,8 @@ class PlaylistModel(QAbstractTableModel):
if len(self.selected_rows) == 0: if len(self.selected_rows) == 0:
# No row selected so clear next track # No row selected so clear next track
if track_sequence.next is not None: if self.track_sequence.next is not None:
track_sequence.set_next(None) self.track_sequence.set_next(None)
return return
if len(self.selected_rows) > 1: if len(self.selected_rows) > 1:
@ -1445,10 +1420,10 @@ class PlaylistModel(QAbstractTableModel):
raise ApplicationError(f"set_next_row: no track_id ({rat=})") raise ApplicationError(f"set_next_row: no track_id ({rat=})")
old_next_row: Optional[int] = None old_next_row: Optional[int] = None
if track_sequence.next: if self.track_sequence.next:
old_next_row = track_sequence.next.row_number old_next_row = self.track_sequence.next.row_number
track_sequence.set_next(rat) self.track_sequence.set_next(rat)
roles = [ roles = [
Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.BackgroundRole,
@ -1513,7 +1488,7 @@ class PlaylistModel(QAbstractTableModel):
# commit changes before refreshing data # commit changes before refreshing data
session.commit() session.commit()
self.refresh_row(session, row_number) self.refresh_row(row_number)
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role]) self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role])
return True return True
@ -1636,9 +1611,8 @@ class PlaylistModel(QAbstractTableModel):
a.row_number for a in self.playlist_rows.values() if a.track_id == track_id a.row_number for a in self.playlist_rows.values() if a.track_id == track_id
] ]
if track_rows: if track_rows:
with db.Session() as session:
for row in track_rows: for row in track_rows:
self.refresh_row(session, row) self.refresh_row(row)
# only invalidate required roles # only invalidate required roles
roles = [ roles = [
Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.BackgroundRole,
@ -1664,19 +1638,19 @@ class PlaylistModel(QAbstractTableModel):
current_track_row = None current_track_row = None
next_track_row = None next_track_row = None
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.current.playlist_id == self.playlist_id and self.track_sequence.current.playlist_id == self.playlist_id
): ):
current_track_row = track_sequence.current.row_number current_track_row = self.track_sequence.current.row_number
# Update current track details now so that they are available # Update current track details now so that they are available
# when we deal with next track row which may be above current # when we deal with next track row which may be above current
# track row. # track row.
self.playlist_rows[current_track_row].set_forecast_start_time( self.playlist_rows[current_track_row].set_forecast_start_time(
update_rows, track_sequence.current.start_time update_rows, self.track_sequence.current.start_time
) )
if track_sequence.next and track_sequence.next.playlist_id == self.playlist_id: if self.track_sequence.next and self.track_sequence.next.playlist_id == self.playlist_id:
next_track_row = track_sequence.next.row_number next_track_row = self.track_sequence.next.row_number
for row_number in range(row_count): for row_number in range(row_count):
rat = self.playlist_rows[row_number] rat = self.playlist_rows[row_number]
@ -1700,11 +1674,11 @@ class PlaylistModel(QAbstractTableModel):
# Set start time for next row if we have a current track # Set start time for next row if we have a current track
if ( if (
row_number == next_track_row row_number == next_track_row
and track_sequence.current and self.track_sequence.current
and track_sequence.current.end_time and self.track_sequence.current.end_time
): ):
next_start_time = rat.set_forecast_start_time( next_start_time = rat.set_forecast_start_time(
update_rows, track_sequence.current.end_time update_rows, self.track_sequence.current.end_time
) )
continue continue
@ -1738,6 +1712,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# Search all columns # Search all columns
self.setFilterKeyColumn(-1) self.setFilterKeyColumn(-1)
self.track_sequence = TrackSequence()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<PlaylistProxyModel: sourceModel={self.sourceModel()}>" return f"<PlaylistProxyModel: sourceModel={self.sourceModel()}>"
@ -1753,37 +1729,37 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if self.sourceModel().is_played_row(source_row): if self.sourceModel().is_played_row(source_row):
# Don't hide current track # Don't hide current track
if ( if (
track_sequence.current self.track_sequence.current
and track_sequence.current.playlist_id and self.track_sequence.current.playlist_id
== self.sourceModel().playlist_id == self.sourceModel().playlist_id
and track_sequence.current.row_number == source_row and self.track_sequence.current.row_number == source_row
): ):
return True return True
# Don't hide next track # Don't hide next track
if ( if (
track_sequence.next self.track_sequence.next
and track_sequence.next.playlist_id and self.track_sequence.next.playlist_id
== self.sourceModel().playlist_id == self.sourceModel().playlist_id
and track_sequence.next.row_number == source_row and self.track_sequence.next.row_number == source_row
): ):
return True return True
# Handle previous track # Handle previous track
if track_sequence.previous: if self.track_sequence.previous:
if ( if (
track_sequence.previous.playlist_id self.track_sequence.previous.playlist_id
!= self.sourceModel().playlist_id != self.sourceModel().playlist_id
or track_sequence.previous.row_number != source_row or self.track_sequence.previous.row_number != source_row
): ):
# This row isn't our previous track: hide it # This row isn't our previous track: hide it
return False return False
if track_sequence.current and track_sequence.current.start_time: if self.track_sequence.current and self.track_sequence.current.start_time:
# This row is our previous track. Don't hide it # This row is our previous track. Don't hide it
# until HIDE_AFTER_PLAYING_OFFSET milliseconds # until HIDE_AFTER_PLAYING_OFFSET milliseconds
# after current track has started # after current track has started
if track_sequence.current.start_time and dt.datetime.now() > ( if self.track_sequence.current.start_time and dt.datetime.now() > (
track_sequence.current.start_time self.track_sequence.current.start_time
+ dt.timedelta( + dt.timedelta(
milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET
) )

533
app/playlistrow.py Normal file
View File

@ -0,0 +1,533 @@
# Standard library imports
import datetime as dt
from typing import Any
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QThread,
)
# Third party imports
from pyqtgraph import PlotWidget # type: ignore
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore
import numpy as np
import pyqtgraph as pg # type: ignore
# App imports
from classes import ApplicationError, MusicMusterSignals, PlaylistRowDTO, singleton
from config import Config
import helpers
from log import log
from music_manager import Music
import repository
class PlaylistRow:
"""
Object to manage playlist row and track.
"""
def __init__(self, dto: PlaylistRowDTO) -> None:
"""
The dto object will include a Tracks object if this row has a track.
"""
self.dto = dto
self.music = Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
self.end_of_track_signalled: bool = False
self.end_time: dt.datetime | None = None
self.fade_graph: Any | None = None
self.fade_graph_start_updates: dt.datetime | None = None
self.forecast_end_time: dt.datetime | None = None
self.forecast_start_time: dt.datetime | None = None
self.note_bg: str | None = None
self.note_fg: str | None = None
self.resume_marker: float = 0.0
self.row_bg: str | None = None
self.row_fg: str | None = None
self.start_time: dt.datetime | None = None
def __repr__(self) -> str:
return (
f"<PlaylistRow(playlist_id={self.dto.playlist_id}, "
f"row_number={self.dto.row_number}, "
f"playlistrow_id={self.dto.playlistrow_id}, "
f"note={self.dto.note}, track_id={self.dto.track_id}>"
)
# Expose TrackDTO fields as properties
@property
def artist(self):
return self.dto.artist
@property
def bitrate(self):
return self.dto.bitrate
@property
def duration(self):
return self.dto.duration
@property
def fade_at(self):
return self.dto.fade_at
@property
def intro(self):
return self.dto.intro
@property
def lastplayed(self):
return self.dto.lastplayed
@property
def path(self):
return self.dto.path
@property
def silence_at(self):
return self.dto.silence_at
@property
def start_gap(self):
return self.dto.start_gap
@property
def title(self):
return self.dto.title
@property
def track_id(self):
return self.dto.track_id
@track_id.setter
def track_id(self, value: int) -> None:
"""
Adding a track_id should only happen to a header row.
"""
if self.track_id:
raise ApplicationError("Attempting to add track to row with existing track ({self=}")
# TODO: set up write access to track_id. Should only update if
# track_id == 0. Need to update all other track fields at the
# same time.
print("set track_id attribute for {self=}, {value=}")
pass
# Expose PlaylistRowDTO fields as properties
@property
def note(self):
return self.dto.note
@note.setter
def note(self, value: str) -> None:
# TODO set up write access to db
print("set note attribute for {self=}, {value=}")
# self.dto.note = value
@property
def played(self):
return self.dto.played
@played.setter
def played(self, value: bool = True) -> None:
# TODO set up write access to db
print("set played attribute for {self=}")
# self.dto.played = value
@property
def playlist_id(self):
return self.dto.playlist_id
@property
def playlistrow_id(self):
return self.dto.playlistrow_id
@property
def row_number(self):
return self.dto.row_number
@row_number.setter
def row_number(self, value: int) -> None:
# TODO do we need to set up write access to db?
self.dto.row_number = value
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def drop3db(self, enable: bool) -> None:
"""
If enable is true, drop output by 3db else restore to full volume
"""
if enable:
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.music.is_playing()
def play(self, position: float | None = None) -> None:
"""Play track"""
now = dt.datetime.now()
self.start_time = now
# Initialise player
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def set_forecast_start_time(
self, modified_rows: list[int], start: dt.datetime | None
) -> dt.datetime | None:
"""
Set forecast start time for this row
Update passed modified rows list if we changed the row.
Return new start time
"""
changed = False
if self.forecast_start_time != start:
self.forecast_start_time = start
changed = True
if start is None:
if self.forecast_end_time is not None:
self.forecast_end_time = None
changed = True
new_start_time = None
else:
end_time = start + dt.timedelta(milliseconds=self.duration)
new_start_time = end_time
if self.forecast_end_time != end_time:
self.forecast_end_time = end_time
changed = True
if changed and self.row_number not in modified_rows:
modified_rows.append(self.row_number)
return new_start_time
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
if self.fade_graph:
self.fade_graph.clear()
def time_playing(self) -> int:
"""
Return time track has been playing in milliseconds, zero if not playing
"""
if self.start_time is None:
return 0
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.fade_at - self.time_playing()
def time_to_silence(self) -> int:
"""
Return milliseconds until silent. Return zero if we're not playing.
"""
if self.start_time is None:
return 0
return self.silence_at - self.time_playing()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
plr: PlaylistRow,
track_path: str,
track_fade_at: int,
track_silence_at: int,
) -> None:
super().__init__()
self.plr = plr
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self) -> None:
"""
Create fade curve and add to PlaylistTrack object
"""
fc = FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.plr.fade_graph = fc
self.finished.emit()
class FadeCurve:
GraphWidget: PlotWidget | None = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.curve: PlotDataItem | None = None
self.region: LinearRegionItem | None = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self) -> None:
if self.GraphWidget:
self.curve = self.GraphWidget.plot(self.graph_array)
if self.curve:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
else:
log.debug("_FadeCurve.plot: no curve")
else:
log.debug("_FadeCurve.plot: no GraphWidget")
def tick(self, play_time: int) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
@singleton
class TrackSequence:
"""
Maintain a list of which track (if any) is next, current and
previous. A track can only be previous after being current, and can
only be current after being next. If one of the tracks listed here
moves, the row_number and/or playlist_id will change.
"""
def __init__(self) -> None:
"""
Set up storage for the three monitored tracks
"""
self.next: PlaylistRow | None = None
self.current: PlaylistRow | None = None
self.previous: PlaylistRow | None = None
def set_next(self, plr: PlaylistRow | None) -> None:
"""
Set the 'next' track to be passed PlaylistRow. Clear any previous
next track. If passed PlaylistRow is None just clear existing
next track.
"""
# Clear any existing fade graph
if self.next and self.next.fade_graph:
self.next.fade_graph.clear()
if plr is None:
self.next = None
else:
self.next = plr
self.create_fade_graph()
def move_next_to_current(self) -> None:
"""
Make the next track the current track
"""
self.current = self.next
self.next = None
def move_current_to_previous(self) -> None:
"""
Make the current track the previous track
"""
if self.current is None:
raise ApplicationError("Tried to move non-existent track from current to previous")
# Dereference the fade curve so it can be garbage collected
self.current.fade_graph = None
self.previous = self.current
self.current = None
def move_previous_to_next(self) -> None:
"""
Make the previous track the next track
"""
self.next = self.previous
self.previous = None
def create_fade_graph(self) -> None:
"""
Initialise and add FadeCurve in a thread as it's slow
"""
self.fadecurve_thread = QThread()
if self.next is None:
raise ApplicationError("hell in a handcart")
self.worker = _AddFadeCurve(
self.next,
track_path=self.next.path,
track_fade_at=self.next.fade_at,
track_silence_at=self.next.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def update(self) -> None:
"""
If a PlaylistRow is edited (moved, title changed, etc), the
playlistrow_id won't change. We can retrieve the PlaylistRow
using the playlistrow_id and update the stored PlaylistRow.
"""
for ts in [self.next, self.current, self.previous]:
if not ts:
continue
playlist_row_dto = repository.get_playlist_row(ts.playlistrow_id)
if not playlist_row_dto:
raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}")
ts = PlaylistRow(playlist_row_dto)

View File

@ -37,7 +37,7 @@ from PyQt6.QtWidgets import (
from audacity_controller import AudacityController from audacity_controller import AudacityController
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
from config import Config from config import Config
from dialogs import TrackSelectDialog from dialogs import TrackInsertDialog
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
ms_to_mmss, ms_to_mmss,
@ -46,7 +46,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, Settings from models import db, Settings
from music_manager import track_sequence from playlistrow import TrackSequence
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
if TYPE_CHECKING: if TYPE_CHECKING:
@ -278,6 +278,7 @@ class PlaylistTab(QTableView):
self.musicmuster = musicmuster self.musicmuster = musicmuster
self.playlist_id = model.sourceModel().playlist_id self.playlist_id = model.sourceModel().playlist_id
self.track_sequence = TrackSequence()
# Set up widget # Set up widget
self.setItemDelegate(PlaylistDelegate(self, model.sourceModel())) self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
@ -408,8 +409,8 @@ class PlaylistTab(QTableView):
# that moved row the next track # that moved row the next track
set_next_row: Optional[int] = None set_next_row: Optional[int] = None
if ( if (
track_sequence.current self.track_sequence.current
and to_model_row == track_sequence.current.row_number + 1 and to_model_row == self.track_sequence.current.row_number + 1
): ):
set_next_row = to_model_row set_next_row = to_model_row
@ -461,12 +462,14 @@ class PlaylistTab(QTableView):
Toggle drag behaviour according to whether rows are selected Toggle drag behaviour according to whether rows are selected
""" """
selected_rows = self.get_selected_rows() selected_row_numbers = self.get_selected_rows()
self.musicmuster.current.selected_rows = selected_rows
self.get_base_model().set_selected_rows(selected_rows)
# Signal selected rows to model
self.signals.signal_playlist_selected_rows.emit(self.playlist_id, selected_row_numbers)
# Put sum of selected tracks' duration in status bar
# If no rows are selected, we have nothing to do # If no rows are selected, we have nothing to do
if len(selected_rows) == 0: if len(selected_row_numbers) == 0:
self.musicmuster.lblSumPlaytime.setText("") self.musicmuster.lblSumPlaytime.setText("")
else: else:
if not self.musicmuster.disable_selection_timing: if not self.musicmuster.disable_selection_timing:
@ -517,11 +520,9 @@ class PlaylistTab(QTableView):
return return
with db.Session() as session: with db.Session() as session:
dlg = TrackSelectDialog( dlg = TrackInsertDialog(
parent=self.musicmuster, parent=self.musicmuster,
session=session, playlist_id=self.playlist_id,
new_row_number=model_row_number,
base_model=self.get_base_model(),
add_to_header=True, add_to_header=True,
) )
dlg.exec() dlg.exec()
@ -538,12 +539,12 @@ class PlaylistTab(QTableView):
header_row = self.get_base_model().is_header_row(model_row_number) header_row = self.get_base_model().is_header_row(model_row_number)
track_row = not header_row track_row = not header_row
if track_sequence.current: if self.track_sequence.current:
this_is_current_row = model_row_number == track_sequence.current.row_number this_is_current_row = model_row_number == self.track_sequence.current.row_number
else: else:
this_is_current_row = False this_is_current_row = False
if track_sequence.next: if self.track_sequence.next:
this_is_next_row = model_row_number == track_sequence.next.row_number this_is_next_row = model_row_number == self.track_sequence.next.row_number
else: else:
this_is_next_row = False this_is_next_row = False
track_path = base_model.get_row_info(model_row_number).path track_path = base_model.get_row_info(model_row_number).path
@ -760,8 +761,8 @@ class PlaylistTab(QTableView):
# Don't delete current or next tracks # Don't delete current or next tracks
selected_row_numbers = self.selected_model_row_numbers() selected_row_numbers = self.selected_model_row_numbers()
for ts in [ for ts in [
track_sequence.next, self.track_sequence.next,
track_sequence.current, self.track_sequence.current,
]: ]:
if ts: if ts:
if ( if (
@ -1122,7 +1123,7 @@ class PlaylistTab(QTableView):
# Update musicmuster # Update musicmuster
self.musicmuster.current.playlist_id = self.playlist_id self.musicmuster.current.playlist_id = self.playlist_id
self.musicmuster.current.selected_rows = self.get_selected_rows() self.musicmuster.current.selected_row_numbers = self.get_selected_rows()
self.musicmuster.current.base_model = self.get_base_model() self.musicmuster.current.base_model = self.get_base_model()
self.musicmuster.current.proxy_model = self.model() self.musicmuster.current.proxy_model = self.model()
@ -1131,6 +1132,6 @@ class PlaylistTab(QTableView):
def _unmark_as_next(self) -> None: def _unmark_as_next(self) -> None:
"""Rescan track""" """Rescan track"""
track_sequence.set_next(None) self.track_sequence.set_next(None)
self.clear_selection() self.clear_selection()
self.signals.next_track_changed_signal.emit() self.signals.next_track_changed_signal.emit()

View File

@ -40,7 +40,7 @@ from helpers import (
) )
from log import log from log import log
from models import db, Playdates, Tracks from models import db, Playdates, Tracks
from music_manager import PlaylistRow from playlistrow import PlaylistRow
@dataclass @dataclass

View File

@ -11,11 +11,12 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.sql.elements import BinaryExpression
from classes import ApplicationError, PlaylistRowDTO from classes import ApplicationError, PlaylistRowDTO
# App imports # App imports
from classes import PlaylistDTO, TrackDTO from classes import PlaylistDTO, TrackDTO
from app import helpers import helpers
from log import log from log import log
from models import ( from models import (
db, db,
@ -23,6 +24,7 @@ from models import (
Playdates, Playdates,
PlaylistRows, PlaylistRows,
Playlists, Playlists,
Settings,
Tracks, Tracks,
) )
@ -65,7 +67,7 @@ def get_colour(text: str, foreground: bool = False) -> str:
# Track functions # Track functions
def add_track_to_header(self, playlistrow_id: int, track_id: int) -> None: def add_track_to_header(playlistrow_id: int, track_id: int) -> None:
""" """
Add a track to this (header) row Add a track to this (header) row
""" """
@ -87,13 +89,28 @@ def create_track(path: str) -> TrackDTO:
metadata = helpers.get_all_track_metadata(path) metadata = helpers.get_all_track_metadata(path)
with db.Session() as session: with db.Session() as session:
try: try:
track = Tracks(session=session, **metadata) track = Tracks(
session=session,
path=str(metadata["path"]),
title=str(metadata["title"]),
artist=str(metadata["artist"]),
duration=int(metadata["duration"]),
start_gap=int(metadata["start_gap"]),
fade_at=int(metadata["fade_at"]),
silence_at=int(metadata["silence_at"]),
bitrate=int(metadata["bitrate"]),
)
track_id = track.id track_id = track.id
session.commit() session.commit()
except Exception: except Exception:
raise ApplicationError("Can't create Track") raise ApplicationError("Can't create Track")
return track_by_id(track_id) new_track = track_by_id(track_id)
if not new_track:
raise ApplicationError("Unable to create new track")
return new_track
def track_by_id(track_id: int) -> TrackDTO | None: def track_by_id(track_id: int) -> TrackDTO | None:
@ -154,21 +171,79 @@ def track_by_id(track_id: int) -> TrackDTO | None:
return dto return dto
def _tracks_like(where: BinaryExpression) -> list[TrackDTO]:
"""
Return tracks selected by where
"""
# Alias PlaydatesTable for subquery
LatestPlaydate = aliased(Playdates)
# Subquery: latest playdate for each track
latest_playdate_subq = (
select(
LatestPlaydate.track_id,
func.max(LatestPlaydate.lastplayed).label("lastplayed"),
)
.group_by(LatestPlaydate.track_id)
.subquery()
)
stmt = (
select(
Tracks.id.label("track_id"),
Tracks.artist,
Tracks.bitrate,
Tracks.duration,
Tracks.fade_at,
Tracks.intro,
Tracks.path,
Tracks.silence_at,
Tracks.start_gap,
Tracks.title,
latest_playdate_subq.c.lastplayed,
)
.outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id)
.where(where)
)
results: list[TrackDTO] = []
with db.Session() as session:
records = session.execute(stmt).all()
for record in records:
dto = TrackDTO(
artist=record.artist,
bitrate=record.bitrate,
duration=record.duration,
fade_at=record.fade_at,
intro=record.intro,
lastplayed=record.lastplayed,
path=record.path,
silence_at=record.silence_at,
start_gap=record.start_gap,
title=record.title,
track_id=record.track_id,
)
results.append(dto)
return results
def tracks_like_artist(filter_str: str) -> list[TrackDTO]:
"""
Return tracks where artist is like filter
"""
return _tracks_like(Tracks.artist.ilike(f"%{filter_str}%"))
def tracks_like_title(filter_str: str) -> list[TrackDTO]: def tracks_like_title(filter_str: str) -> list[TrackDTO]:
""" """
Return tracks where title is like filter Return tracks where title is like filter
""" """
# TODO: add in playdates as per Tracks.search_titles return _tracks_like(Tracks.title.ilike(f"%{filter_str}%"))
with db.Session() as session:
stmt = select(Tracks).where(Tracks.title.ilike(f"%{filter_str}%"))
results = (
session.execute(stmt).scalars().unique().all()
) # `scalars()` extracts ORM objects
return [
TrackDTO(**{k: v for k, v in vars(t).items() if not k.startswith("_")})
for t in results
]
# Playlist functions # Playlist functions
@ -244,10 +319,14 @@ def create_playlist(name: str, template_id: int) -> PlaylistDTO:
except Exception: except Exception:
raise ApplicationError("Can't create Playlist") raise ApplicationError("Can't create Playlist")
return playlist_by_id(playlist_id) new_playlist = playlist_by_id(playlist_id)
if not new_playlist:
raise ApplicationError("Can't retrieve new Playlist")
return new_playlist
def get_playlist_row(playlist_row_id: int) -> PlaylistRowDTO | None: def get_playlist_row(playlistrow_id: int) -> PlaylistRowDTO | None:
""" """
Return specific row DTO Return specific row DTO
""" """
@ -286,7 +365,7 @@ def get_playlist_row(playlist_row_id: int) -> PlaylistRowDTO | None:
) )
.outerjoin(Tracks, PlaylistRows.track_id == Tracks.id) .outerjoin(Tracks, PlaylistRows.track_id == Tracks.id)
.outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id) .outerjoin(latest_playdate_subq, Tracks.id == latest_playdate_subq.c.track_id)
.where(PlaylistRows.id == playlist_row_id) .where(PlaylistRows.id == playlistrow_id)
.order_by(PlaylistRows.row_number) .order_by(PlaylistRows.row_number)
) )
@ -428,7 +507,9 @@ def get_playlist_rows(playlist_id: int) -> list[PlaylistRowDTO]:
return dto_list return dto_list
def insert_row(playlist_id: int, row_number: int, track_id: int, note: str) -> PlaylistRowDTO: def insert_row(
playlist_id: int, row_number: int, track_id: int | None, note: str
) -> PlaylistRowDTO:
""" """
Insert a new row into playlist and return new row DTO Insert a new row into playlist and return new row DTO
""" """
@ -455,7 +536,11 @@ def insert_row(playlist_id: int, row_number: int, track_id: int, note: str) -> P
# Sanity check # Sanity check
_check_row_number_sequence(session=session, playlist_id=playlist_id, fix=False) _check_row_number_sequence(session=session, playlist_id=playlist_id, fix=False)
return get_playlist_row(playlist_row_id=playlist_row_id) new_playlist_row = get_playlist_row(playlistrow_id=playlist_row_id)
if not new_playlist_row:
raise ApplicationError("Can't retrieve new playlist row")
return new_playlist_row
def playlist_by_id(playlist_id: int) -> PlaylistDTO | None: def playlist_by_id(playlist_id: int) -> PlaylistDTO | None:
@ -485,3 +570,34 @@ def playlist_by_id(playlist_id: int) -> PlaylistDTO | None:
) )
return dto return dto
# Misc
def get_setting(name: str) -> int | None:
"""
Get int setting
"""
with db.Session() as session:
record = session.execute(select(Settings).where(Settings.name == name)).one_or_none()
if not record:
return None
return record.f_int
def set_setting(name: str, value: int) -> None:
"""
Add int setting
"""
with db.Session() as session:
record = session.execute(select(Settings).where(Settings.name == name)).one_or_none()
if not record:
record = Settings(session=session, name=name)
if not record:
raise ApplicationError("Can't create Settings record")
record.f_int = value
session.commit()

View File

@ -1,22 +0,0 @@
# Standard library imports
# PyQt imports
# Third party imports
import vlc # type: ignore
# App imports
from classes import singleton
@singleton
class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
def __init__(self) -> None:
self.vlc_instance = vlc.Instance()
def get_instance(self) -> vlc.Instance:
return self.vlc_instance

View File

@ -46,19 +46,19 @@ class MyTestCase(unittest.TestCase):
self.track2 = repository.create_track(track2_path) self.track2 = repository.create_track(track2_path)
# Add tracks and header to playlist # Add tracks and header to playlist
repository.insert_row( self.row0 = repository.insert_row(
self.playlist.playlist_id, self.playlist.playlist_id,
row_number=0, row_number=0,
track_id=self.track1.track_id, track_id=self.track1.track_id,
note="track 1", note="track 1",
) )
repository.insert_row( self.row1 = repository.insert_row(
self.playlist.playlist_id, self.playlist.playlist_id,
row_number=1, row_number=1,
track_id=0, track_id=0,
note="Header row", note="Header row",
) )
repository.insert_row( self.row2 = repository.insert_row(
self.playlist.playlist_id, self.playlist.playlist_id,
row_number=2, row_number=2,
track_id=self.track2.track_id, track_id=self.track2.track_id,
@ -70,7 +70,9 @@ class MyTestCase(unittest.TestCase):
db.drop_all() db.drop_all()
def test_xxx(self): def test_add_track_to_header(self):
"""Comment""" """Add a track to a header row"""
pass repository.add_track_to_header(self.row1.playlistrow_id, self.track2.track_id)
result = repository.get_playlist_row(self.row1.playlistrow_id)
assert result.track_id == self.track2.track_id