diff --git a/alembic.ini b/alembic.ini index c52f478..b604d8c 100644 --- a/alembic.ini +++ b/alembic.ini @@ -5,7 +5,7 @@ # there are two components separated by a colon: # the left part is the import path to the module containing the database instance # the right part is the name of the database instance, typically 'db' -alchemical_db = models:db +alchemical_db = ds:db # path to migration scripts script_location = migrations diff --git a/app/classes.py b/app/classes.py index 4424bdf..60da8b0 100644 --- a/app/classes.py +++ b/app/classes.py @@ -1,7 +1,7 @@ # Standard library imports from __future__ import annotations - from dataclasses import dataclass +import datetime as dt from enum import auto, Enum import functools import threading @@ -46,6 +46,68 @@ def singleton(cls): return wrapper_singleton +# DTOs +@dataclass +class PlaylistDTO: + playlist_id: int + name: str + open: bool = False + favourite: bool = False + is_template: bool = False + + +@dataclass +class QueryDTO: + query_id: int + name: str + favourite: bool + filter: Filter + + +@dataclass +class TrackDTO: + track_id: int + artist: str + bitrate: int + duration: int + fade_at: int + intro: int | None + path: str + silence_at: int + start_gap: int + title: str + lastplayed: dt.datetime | None + + +@dataclass +class PlaylistRowDTO: + note: str + played: bool + playlist_id: int + playlistrow_id: int + row_number: int + track: TrackDTO | None + + +@dataclass +class PlaydatesDTO(TrackDTO): + playdate_id: int + lastplayed: dt.datetime + + +@dataclass +class NoteColoursDTO: + notecolour_id: int + substring: str + colour: str + enabled: bool = True + foreground: str | None = None + is_regex: bool = False + is_casesensitive: bool = False + order: int | None = None + strip_substring: bool = True + + class ApplicationError(Exception): """ Custom exception @@ -61,6 +123,10 @@ class AudioMetadata(NamedTuple): class Col(Enum): + """ + Columns in playlist + """ + START_GAP = 0 TITLE = auto() ARTIST = auto() @@ -80,6 +146,10 @@ class FileErrors(NamedTuple): @dataclass class Filter: + """ + Filter used in queries to select tracks + """ + version: int = 1 path_type: str = "contains" path: str = "" @@ -91,31 +161,6 @@ class Filter: duration_unit: str = "minutes" -@singleton -@dataclass -class MusicMusterSignals(QObject): - """ - Class for all MusicMuster signals. See: - - https://zetcode.com/gui/pyqt5/eventssignals/ - - https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class - """ - - begin_reset_model_signal = pyqtSignal(int) - enable_escape_signal = pyqtSignal(bool) - end_reset_model_signal = pyqtSignal(int) - next_track_changed_signal = pyqtSignal() - resize_rows_signal = pyqtSignal(int) - search_songfacts_signal = pyqtSignal(str) - search_wikipedia_signal = pyqtSignal(str) - show_warning_signal = pyqtSignal(str, str) - span_cells_signal = pyqtSignal(int, int, int, int, int) - status_message_signal = pyqtSignal(str, int) - track_ended_signal = pyqtSignal() - - def __post_init__(self): - super().__init__() - - class PlaylistStyle(QProxyStyle): def drawPrimitive(self, element, option, painter, widget=None): """ @@ -135,6 +180,10 @@ class PlaylistStyle(QProxyStyle): class QueryCol(Enum): + """ + Columns in querylist + """ + TITLE = 0 ARTIST = auto() DURATION = auto() @@ -152,3 +201,92 @@ class Tags(NamedTuple): class TrackInfo(NamedTuple): track_id: int row_number: int + + +# Classes for signals +@dataclass +class InsertRows: + playlist_id: int + from_row: int + to_row: int + + +@dataclass +class InsertTrack: + playlist_id: int + track_id: int | None + note: str + + +@dataclass +class SelectedRows: + playlist_id: int + rows: list[int] + + +@dataclass +class TrackAndPlaylist: + playlist_id: int + track_id: int + + +@singleton +@dataclass +class MusicMusterSignals(QObject): + """ + Class for all MusicMuster signals. See: + - https://zetcode.com/gui/pyqt5/eventssignals/ + - https://stackoverflow.com/questions/62654525/emit-a-signal-from-another-class-to-main-class + """ + + # Used to en/disable escape as a shortcut key to "clear selection". + # We disable it when editing a field in the playlist because we use + # escape there to abandon an edit. + enable_escape_signal = pyqtSignal(bool) + + # Signals that the playlist_id passed should resize all rows. + resize_rows_signal = pyqtSignal(int) + + # Displays a warning dialog + show_warning_signal = pyqtSignal(str, str) + + # Signal to add a track to a header row + signal_add_track_to_header = pyqtSignal(TrackAndPlaylist) + + # Signal to receving model that rows will be / have been inserter + signal_begin_insert_rows = pyqtSignal(InsertRows) + signal_end_insert_rows = pyqtSignal(int) + + # TBD + signal_insert_track = pyqtSignal(InsertTrack) + + # Keep track of which rows are selected (between playlist and model) + signal_playlist_selected_rows = pyqtSignal(SelectedRows) + + # Signal to model that selected row is to be next row + signal_set_next_row = pyqtSignal(int) + + # signal_set_next_track takes a PlaylistRow as an argument. We can't + # specify that here as it requires us to import PlaylistRow from + # playlistrow.py, which itself imports MusicMusterSignals. It tells + # musicmuster to set the passed track as the next one. + signal_set_next_track = pyqtSignal(object) + + # Signals that the next-cued track has changed. Used to update + # playlist headers and track timings. + signal_next_track_changed = pyqtSignal() + + # Emited when a track starts playing + signal_track_started = pyqtSignal() + + # Emitted when track ends or is manually faded + signal_track_ended = pyqtSignal(int) + + # Used by model to signal spanning of cells to playlist for headers + span_cells_signal = pyqtSignal(int, int, int, int, int) + + # Dispay status message to user + status_message_signal = pyqtSignal(str, int) + + def __post_init__(self): + super().__init__() diff --git a/app/config.py b/app/config.py index c7d81a6..19aa240 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,7 @@ class Config(object): COLOUR_QUERYLIST_SELECTED = "#d3ffd3" COLOUR_UNREADABLE = "#dc3545" COLOUR_WARNING_TIMER = "#ffc107" + DB_NOT_FOUND = "Database not found" DBFS_SILENCE = -50 DEFAULT_COLUMN_WIDTH = 200 DISPLAY_SQL = False @@ -112,6 +113,8 @@ class Config(object): PLAYLIST_ICON_CURRENT = ":/icons/green-circle.png" PLAYLIST_ICON_NEXT = ":/icons/yellow-circle.png" PLAYLIST_ICON_TEMPLATE = ":/icons/redstar.png" + PLAYLIST_PENDING_MOVE = -1 + PLAYLIST_FAILED_MOVE = -2 PREVIEW_ADVANCE_MS = 5000 PREVIEW_BACK_MS = 5000 PREVIEW_END_BUFFER_MS = 1000 diff --git a/app/dbtables.py b/app/dbtables.py index 6c6cbe8..e5b6d95 100644 --- a/app/dbtables.py +++ b/app/dbtables.py @@ -15,13 +15,13 @@ from sqlalchemy import ( String, ) from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import ( Mapped, mapped_column, relationship, ) +from sqlalchemy.orm.session import Session from sqlalchemy.types import TypeDecorator, TEXT # App imports @@ -49,10 +49,10 @@ class JSONEncodedDict(TypeDecorator): # Database classes -class NoteColoursTable(Model): +class NoteColours(Model): __tablename__ = "notecolours" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + notecolour_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) substring: Mapped[str] = mapped_column(String(256), index=True, unique=True) colour: Mapped[str] = mapped_column(String(21), index=False) enabled: Mapped[bool] = mapped_column(default=True, index=True) @@ -64,47 +64,83 @@ class NoteColoursTable(Model): def __repr__(self) -> str: return ( - f"" ) + def __init__( + self, + session: Session, + substring: str, + colour: str, + enabled: bool = True, + is_regex: bool = False, + is_casesensitive: bool = False, + order: Optional[int] = 0, + ) -> None: + self.substring = substring + self.colour = colour + self.enabled = enabled + self.is_regex = is_regex + self.is_casesensitive = is_casesensitive + self.order = order -class PlaydatesTable(Model): + session.add(self) + session.commit() + + +class Playdates(Model): __tablename__ = "playdates" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + playdate_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) lastplayed: Mapped[dt.datetime] = mapped_column(index=True) - track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE")) - track: Mapped["TracksTable"] = relationship( - "TracksTable", + track_id: Mapped[int] = mapped_column( + ForeignKey("tracks.track_id", ondelete="CASCADE") + ) + track: Mapped["Tracks"] = relationship( + "Tracks", back_populates="playdates", ) + def __init__( + self, session: Session, track_id: int, when: dt.datetime | None = None + ) -> None: + """Record that track was played""" + + if not when: + self.lastplayed = dt.datetime.now() + else: + self.lastplayed = when + self.track_id = track_id + + session.add(self) + session.commit() + def __repr__(self) -> str: return ( - f"" ) -class PlaylistsTable(Model): +class Playlists(Model): """ Manage playlists """ __tablename__ = "playlists" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + playlist_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(32), unique=True) last_used: Mapped[Optional[dt.datetime]] = mapped_column(DateTime, default=None) tab: Mapped[Optional[int]] = mapped_column(default=None) open: Mapped[bool] = mapped_column(default=False) is_template: Mapped[bool] = mapped_column(default=False) - rows: Mapped[list["PlaylistRowsTable"]] = relationship( - "PlaylistRowsTable", + rows: Mapped[list["PlaylistRows"]] = relationship( + "PlaylistRows", back_populates="playlist", cascade="all, delete-orphan", - order_by="PlaylistRowsTable.row_number", + order_by="PlaylistRows.row_number", ) favourite: Mapped[bool] = mapped_column( Boolean, nullable=False, index=False, default=False @@ -112,29 +148,42 @@ class PlaylistsTable(Model): def __repr__(self) -> str: return ( - f"" ) + def __init__(self, session: Session, name: str, template_id: int) -> None: + """Create playlist with passed name""" -class PlaylistRowsTable(Model): + self.name = name + self.last_used = dt.datetime.now() + + session.add(self) + session.commit() + + # If a template is specified, copy from it + if template_id: + PlaylistRows.copy_playlist(session, template_id, self.playlist_id) + + +class PlaylistRows(Model): __tablename__ = "playlist_rows" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + playlistrow_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) row_number: Mapped[int] = mapped_column(index=True) note: Mapped[str] = mapped_column( String(2048), index=False, default="", nullable=False ) playlist_id: Mapped[int] = mapped_column( - ForeignKey("playlists.id", ondelete="CASCADE"), index=True + ForeignKey("playlists.playlist_id", ondelete="CASCADE"), index=True ) - playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows") + playlist: Mapped[Playlists] = relationship(back_populates="rows") track_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("tracks.id", ondelete="CASCADE") + ForeignKey("tracks.track_id", ondelete="CASCADE") ) - track: Mapped["TracksTable"] = relationship( - "TracksTable", + track: Mapped["Tracks"] = relationship( + "Tracks", back_populates="playlistrows", ) played: Mapped[bool] = mapped_column( @@ -143,19 +192,41 @@ class PlaylistRowsTable(Model): def __repr__(self) -> str: return ( - f"" ) + def __init__( + self, + session: Session, + playlist_id: int, + row_number: int, + note: str = "", + track_id: Optional[int] = None, + ) -> None: + """Create PlaylistRows object""" -class QueriesTable(Model): + self.playlist_id = playlist_id + self.track_id = track_id + self.row_number = row_number + self.note = note + + session.add(self) + session.commit() + + +class Queries(Model): __tablename__ = "queries" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + query_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(128), nullable=False) - _filter_data: Mapped[dict | None] = mapped_column("filter_data", JSONEncodedDict, nullable=False) - favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, index=False, default=False) + _filter_data: Mapped[dict | None] = mapped_column( + "filter_data", JSONEncodedDict, nullable=False + ) + favourite: Mapped[bool] = mapped_column( + Boolean, nullable=False, index=False, default=False + ) def _get_filter(self) -> Filter: """Convert stored JSON dictionary to a Filter object.""" @@ -171,15 +242,31 @@ class QueriesTable(Model): filter = property(_get_filter, _set_filter) def __repr__(self) -> str: - return f"" + return f"" + + def __init__( + self, + session: Session, + name: str, + filter: Filter, + favourite: bool = False, + ) -> None: + """Create new query""" + + self.name = name + self.filter = filter + self.favourite = favourite + + session.add(self) + session.commit() -class SettingsTable(Model): +class Settings(Model): """Manage settings""" __tablename__ = "settings" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + setting_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(64), unique=True) f_datetime: Mapped[Optional[dt.datetime]] = mapped_column(default=None) f_int: Mapped[Optional[int]] = mapped_column(default=None) @@ -187,15 +274,21 @@ class SettingsTable(Model): def __repr__(self) -> str: return ( - f"" ) + def __init__(self, session: Session, name: str) -> None: + self.name = name -class TracksTable(Model): + session.add(self) + session.commit() + + +class Tracks(Model): __tablename__ = "tracks" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + track_id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) artist: Mapped[str] = mapped_column(String(256), index=True) bitrate: Mapped[int] = mapped_column(default=None) duration: Mapped[int] = mapped_column(index=True) @@ -206,14 +299,14 @@ class TracksTable(Model): start_gap: Mapped[int] = mapped_column(index=False) title: Mapped[str] = mapped_column(String(256), index=True) - playlistrows: Mapped[list[PlaylistRowsTable]] = relationship( - "PlaylistRowsTable", + playlistrows: Mapped[list[PlaylistRows]] = relationship( + "PlaylistRows", back_populates="track", cascade="all, delete-orphan", ) playlists = association_proxy("playlistrows", "playlist") - playdates: Mapped[list[PlaydatesTable]] = relationship( - "PlaydatesTable", + playdates: Mapped[list[Playdates]] = relationship( + "Playdates", back_populates="track", cascade="all, delete-orphan", lazy="joined", @@ -221,6 +314,30 @@ class TracksTable(Model): def __repr__(self) -> str: return ( - f"" ) + + def __init__( + self, + session: Session, + path: str, + title: str, + artist: str, + duration: int, + start_gap: int, + fade_at: int, + silence_at: int, + bitrate: int, + ) -> None: + self.path = path + self.title = title + self.artist = artist + self.bitrate = bitrate + self.duration = duration + self.start_gap = start_gap + self.fade_at = fade_at + self.silence_at = silence_at + + session.add(self) + session.commit() diff --git a/app/dialogs.py b/app/dialogs.py index b9fa083..53d04bd 100644 --- a/app/dialogs.py +++ b/app/dialogs.py @@ -2,230 +2,178 @@ from typing import Optional # PyQt imports -from PyQt6.QtCore import QEvent, Qt -from PyQt6.QtGui import QKeyEvent +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, QListWidgetItem, QMainWindow, + QPushButton, + QVBoxLayout, ) # Third party imports -from sqlalchemy.orm.session import Session # App imports -from classes import MusicMusterSignals +from classes import ( + ApplicationError, + InsertTrack, + MusicMusterSignals, + TrackAndPlaylist, +) from helpers import ( - ask_yes_no, get_relative_date, ms_to_mmss, ) -from log import log -from models import Settings, Tracks -from playlistmodel import PlaylistModel -from ui import dlg_TrackSelect_ui +import ds -class TrackSelectDialog(QDialog): - """Select track from database""" - +class TrackInsertDialog(QDialog): def __init__( self, parent: QMainWindow, - session: Session, - new_row_number: int, - base_model: PlaylistModel, + playlist_id: int, add_to_header: Optional[bool] = False, - *args: Qt.WindowType, - **kwargs: Qt.WindowType, ) -> None: """ Subclassed QDialog to manage track selection """ - super().__init__(parent, *args, **kwargs) - self.session = session - self.new_row_number = new_row_number - self.base_model = base_model + super().__init__(parent) + self.playlist_id = playlist_id self.add_to_header = add_to_header - self.ui = dlg_TrackSelect_ui.Ui_Dialog() - self.ui.setupUi(self) - self.ui.btnAdd.clicked.connect(self.add_selected) - self.ui.btnAddClose.clicked.connect(self.add_selected_and_close) - self.ui.btnClose.clicked.connect(self.close) - self.ui.matchList.itemDoubleClicked.connect(self.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() + self.setWindowTitle("Insert Track") - 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 + # Title input on one line + self.title_label = QLabel("Title:") + self.title_edit = QLineEdit() + self.title_edit.textChanged.connect(self.update_list) + + title_layout = QHBoxLayout() + title_layout.addWidget(self.title_label) + title_layout.addWidget(self.title_edit) + + # Track list + self.track_list = QListWidget() + self.track_list.itemDoubleClicked.connect(self.add_clicked) + self.track_list.itemSelectionChanged.connect(self.selection_changed) + + # Note input on one line + self.note_label = QLabel("Note:") + self.note_edit = QLineEdit() + + note_layout = QHBoxLayout() + note_layout.addWidget(self.note_label) + note_layout.addWidget(self.note_edit) + + # Track path + self.path = QLabel() + path_layout = QHBoxLayout() + path_layout.addWidget(self.path) + + # 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) + + width = ds.setting_get("dbdialog_width") or 800 + height = ds.setting_get("dbdialog_height") or 800 self.resize(width, height) - if add_to_header: - self.ui.lblNote.setVisible(False) - self.ui.txtNote.setVisible(False) + self.signals = MusicMusterSignals() - def add_selected(self) -> None: - """Handle Add button""" - - track = None - - if self.ui.matchList.selectedItems(): - item = self.ui.matchList.currentItem() - if item: - track = item.data(Qt.ItemDataRole.UserRole) - - note = self.ui.txtNote.text() - - if not (track or note): + 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 - track_id = None - if track: - track_id = track.id + if text.startswith("a/") and len(text) > 2: + self.tracks = ds.tracks_by_artist(text[2:]) + else: + self.tracks = ds.tracks_by_title(text) - if note and not track_id: - self.base_model.insert_row(self.new_row_number, track_id, note) - self.ui.txtNote.clear() - self.new_row_number += 1 + for track in self.tracks: + duration_str = ms_to_mmss(track.duration) + last_played_str = get_relative_date(track.lastplayed) + 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 - self.ui.txtNote.clear() - self.select_searchtext() + insert_track_data = InsertTrack(self.playlist_id, track_id, note_text) - if track_id is None: - log.error("track_id is None and should not be") - return - - # 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 + self.title_edit.selectAll() + self.title_edit.setFocus() + self.note_edit.clear() + self.title_edit.setFocus() 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 + # The model will have the right-clicked row marked as a + # selected_row so we only need to pass the playlist_id and + # track_id. + self.signals.signal_add_track_to_header.emit( + TrackAndPlaylist(playlist_id=self.playlist_id, track_id=track_id) + ) 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.signals.signal_insert_track.emit(insert_track_data) - self.new_row_number += 1 - - def add_selected_and_close(self) -> None: - """Handle Add and Close button""" - - self.add_selected() + def add_and_close_clicked(self): + self.add_clicked() self.accept() - def chars_typed(self, s: str) -> None: - """Handle text typed in search box""" - - self.ui.matchList.clear() - if len(s) > 0: - if s.startswith("a/") and len(s) > 2: - 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: - last_played = last_playdate.lastplayed - t = QListWidgetItem() - 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: """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 - item = self.ui.matchList.currentItem() - track = item.data(Qt.ItemDataRole.UserRole) - last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None) - if last_playdate: - last_played = last_playdate.lastplayed - else: - last_played = None - path_text = f"{track.path} ({get_relative_date(last_played)})" + tracklist = [t for t in self.tracks if t.track_id == track_id] + if not tracklist: + return + if len(tracklist) > 1: + raise ApplicationError("More than one track returned") + track = tracklist[0] - self.ui.dbPath.setText(path_text) - - 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()) + self.path.setText(track.path) diff --git a/app/ds.py b/app/ds.py new file mode 100644 index 0000000..d0efbde --- /dev/null +++ b/app/ds.py @@ -0,0 +1,1346 @@ +# Standard library imports +import datetime as dt +import os +import re +import sys + +# PyQt imports + +# Third party imports +from dogpile.cache import make_region +from dogpile.cache.api import NO_VALUE +from sqlalchemy import ( + delete, + func, + select, + update, +) +from sqlalchemy.orm import aliased +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.elements import BinaryExpression, ColumnElement + +# App imports +from classes import ( + ApplicationError, + Filter, + NoteColoursDTO, + PlaydatesDTO, + PlaylistDTO, + PlaylistRowDTO, + QueryDTO, + TrackDTO, +) +from config import Config +from log import log, log_call +from dbtables import ( + NoteColours, + Playdates, + PlaylistRows, + Playlists, + Queries, + Settings, + Tracks, +) +from dbmanager import DatabaseManager + +# Establish database connection +DATABASE_URL = os.environ.get("DATABASE_URL") +if DATABASE_URL is None: + raise ValueError("DATABASE_URL is undefined") +if "unittest" in sys.modules and "sqlite" not in DATABASE_URL: + raise ValueError("Unit tests running on non-Sqlite database") +db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db + +# Configure the dogpile cache region +cache_region = make_region().configure( + "dogpile.cache.memory", # Use in-memory caching for now (switch to Redis if needed) + expiration_time=600, # Cache expires after 10 minutes +) + + +# Helper functions +# @log_call +def _remove_substring_case_insensitive(parent_string: str, substring: str) -> str: + """ + Remove all instances of substring from parent string, case insensitively + """ + + # Convert both strings to lowercase for case-insensitive comparison + lower_parent = parent_string.lower() + lower_substring = substring.lower() + + # Initialize the result string + result = parent_string + + # Continue removing the substring until it's no longer found + while lower_substring in lower_parent: + # Find the index of the substring + index = lower_parent.find(lower_substring) + + # Remove the substring + result = result[:index] + result[index + len(substring) :] + + # Update the lowercase versions + lower_parent = result.lower() + + return result + + +# Notecolour functions +def _notecolours_all(session: Session) -> list[NoteColoursDTO]: + """ + Return all notecolour records + """ + + cache_key = "note_colours_all" + cached_result = cache_region.get(cache_key) + + if cached_result is not NO_VALUE: + return cached_result + + # Query the database + records = session.scalars( + select(NoteColours) + .where( + NoteColours.enabled.is_(True), + ) + .order_by(NoteColours.order) + ).all() + + results: list[NoteColoursDTO] = [] + for record in records: + result = NoteColoursDTO( + notecolour_id=record.notecolour_id, + substring=record.substring, + colour=record.colour, + enabled=record.enabled, + foreground=record.foreground, + is_regex=record.is_regex, + is_casesensitive=record.is_casesensitive, + order=record.order, + strip_substring=record.strip_substring, + ) + results.append(result) + + cache_region.set(cache_key, results) + + return results + + +def _notecolors_get_notecolours_dto(text: str) -> tuple[NoteColoursDTO | None, str]: + """ + Parse text and return first matching colour record or None + """ + + with db.Session() as session: + for rec in _notecolours_all(session): + if rec.is_regex: + flags = re.UNICODE + if not rec.is_casesensitive: + flags |= re.IGNORECASE + p = re.compile(rec.substring, flags) + if p.match(text): + if rec.strip_substring: + return_text = re.sub(p, "", text) + else: + return_text = text + return (rec, return_text) + else: + if rec.is_casesensitive: + if rec.substring in text: + return_text = text.replace(rec.substring, "") + return (rec, return_text) + else: + if rec.substring.lower() in text.lower(): + return_text = _remove_substring_case_insensitive( + text, rec.substring + ) + return (rec, return_text) + + return (None, text) + + +def notecolours_get_colour(text: str, foreground: bool = False) -> str: + """ + Parse text and return background (foreground if foreground==True) + colour string if matched, else None + """ + + (rec, _) = _notecolors_get_notecolours_dto(text) + if rec is None: + return "" + elif foreground: + return rec.foreground or "" + else: + return rec.colour + + +# @log_call +def notecolours_remove_colour_substring(text: str) -> str: + """ + Remove text that identifies the colour to be used if strip_substring is True + """ + + (rec, stripped_text) = _notecolors_get_notecolours_dto(text) + + return stripped_text + + +# Track functions +# @log_call +def _tracks_where( + query: BinaryExpression | ColumnElement[bool], +) -> list[TrackDTO]: + """ + filter_by_last_played: bool = False, + last_played_before: dt.datetime | None = None, + Return tracks selected by query + """ + + # Alibas PlaydatesTable for subquery + LatestPlaydate = aliased(Playdates) + + # Create a 'latest playdate' subquery + latest_playdate_subq = ( + select( + LatestPlaydate.track_id, + func.max(LatestPlaydate.lastplayed).label("lastplayed"), + ) + .group_by(LatestPlaydate.track_id) + .subquery() + ) + stmt = ( + select( + Tracks.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.track_id == latest_playdate_subq.c.track_id + ) + .where(query) + ) + + 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 + + +# @log_call +def track_add_to_header(playlistrow_id: int, track_id: int) -> None: + """ + Add a track to this (header) row + """ + + with db.Session() as session: + session.execute( + update(PlaylistRows) + .where(PlaylistRows.playlistrow_id == playlistrow_id) + .values(track_id=track_id) + ) + session.commit() + + +def tracks_all() -> list[TrackDTO]: + """Return a list of all tracks""" + + return _tracks_where(Tracks.track_id > 0) + + +def tracks_by_artist(filter_str: str) -> list[TrackDTO]: + """ + Return tracks where artist is like filter + """ + + return _tracks_where(Tracks.artist.ilike(f"%{filter_str}%")) + + +def track_by_id(track_id: int) -> TrackDTO | None: + """ + Return track with specified id + """ + + track_list = _tracks_where(Tracks.track_id == track_id) + if not track_list: + return None + if len(track_list) > 1: + raise ApplicationError(f"Duplicate {track_id=}") + return track_list[0] + + +def track_by_path(path: str) -> TrackDTO | None: + """ + Return track with passed path or None + """ + + track_list = _tracks_where(Tracks.path.ilike(path)) + if not track_list: + return None + if len(track_list) > 1: + raise ApplicationError(f"Duplicate {path=}") + return track_list[0] + + +def tracks_by_title(filter_str: str) -> list[TrackDTO]: + """ + Return tracks where title is like filter + """ + + return _tracks_where(Tracks.title.ilike(f"%{filter_str}%")) + + +# @log_call +def track_create(metadata: dict[str, str | int | float]) -> TrackDTO: + """ + Create a track db entry from a track path and return the DTO + """ + + with db.Session() as session: + try: + 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.track_id + session.commit() + except Exception: + raise ApplicationError("Can't create Track") + + new_track = track_by_id(track_id) + if not new_track: + raise ApplicationError("Unable to create new track") + + return new_track + + +def track_delete(track_id: int) -> None: + """Delete track""" + + with db.Session() as session: + track = session.get(Tracks, track_id) + session.delete(track) + session.commit() + + +def tracks_filtered(filter: Filter) -> list[TrackDTO]: + """ + Return tracks matching filter + """ + + query = select(Tracks) + + # Path specification + if filter.path: + if filter.path_type == "contains": + query = query.where(Tracks.path.ilike(f"%{filter.path}%")) + elif filter.path_type == "excluding": + query = query.where(Tracks.path.notilike(f"%{filter.path}%")) + else: + raise ApplicationError(f"Can't process filter path ({filter=})") + + # Duration specification + seconds_duration = filter.duration_number + if filter.duration_unit == Config.FILTER_DURATION_MINUTES: + seconds_duration *= 60 + elif filter.duration_unit != Config.FILTER_DURATION_SECONDS: + raise ApplicationError(f"Can't process filter duration ({filter=})") + + if filter.duration_type == Config.FILTER_DURATION_LONGER: + query = query.where(Tracks.duration >= seconds_duration) + elif filter.duration_unit == Config.FILTER_DURATION_SHORTER: + query = query.where(Tracks.duration <= seconds_duration) + else: + raise ApplicationError(f"Can't process filter duration type ({filter=})") + + # Process comparator + if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER: + # Select tracks that have never been played + query = query.outerjoin(Playdates, Tracks.track_id == Playdates.track_id).where( + Playdates.playdate_id.is_(None) + ) + else: + # Last played specification + now = dt.datetime.now() + # Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME + before = now + # If not ANYTIME, set 'before' appropriates + if filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME: + if filter.last_played_unit == Config.FILTER_PLAYED_DAYS: + before = now - dt.timedelta(days=filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS: + before = now - dt.timedelta(days=7 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS: + before = now - dt.timedelta(days=30 * filter.last_played_number) + elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS: + before = now - dt.timedelta(days=365 * filter.last_played_number) + + subquery = ( + select( + Playdates.track_id, + func.max(Playdates.lastplayed).label("max_last_played"), + ) + .group_by(Playdates.track_id) + .subquery() + ) + query = query.join(subquery, Tracks.track_id == subquery.c.track_id).where( + subquery.c.max_last_played < before + ) + + results: list[TrackDTO] = [] + with db.Session() as session: + records = session.scalars(query).unique().all() + for record in records: + if record.playdates: + last_played = record.playdates[0].lastplayed + else: + last_played = None + dto = TrackDTO( + artist=record.artist, + bitrate=record.bitrate, + duration=record.duration, + fade_at=record.fade_at, + intro=record.intro, + lastplayed=last_played, + 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 + + +# @log_call +def track_update(track_id: int, metadata: dict[str, str | int | float]) -> TrackDTO: + """ + Update an existing track db entry return the DTO + """ + + with db.Session() as session: + track = session.get(Tracks, track_id) + + if not track: + raise ApplicationError(f"Can't retrieve Track ({track_id=})") + + for key, value in metadata.items(): + if hasattr(track, key): + setattr(track, key, value) + else: + raise ApplicationError(f"Tried to set attribute {key} on {track}") + + session.commit() + + updated_track = track_by_id(track_id) + if not updated_track: + raise ApplicationError("Unable to retrieve updated track") + + return updated_track + + +# Playlist functions +def _playlist_check_playlist( + session: Session, playlist_id: int, fix: bool = False +) -> None: + """ + Ensure the row numbers are contiguous. Fix and log if fix==True, + else raise ApplicationError. + """ + + fixed = False + + playlist_rows = ( + session.execute( + select(PlaylistRows) + .where(PlaylistRows.playlist_id == playlist_id) + .order_by(PlaylistRows.row_number) + ) + .scalars() + .all() + ) + for idx, plr in enumerate(playlist_rows): + if plr.row_number == idx: + continue + + msg = ( + "_check_playlist_integrity: incorrect row number " + f"({plr.playlistrow_id=}, {plr.row_number=}, {idx=})" + ) + if fix: + log.debug(msg) + plr.row_number = idx + fixed = True + else: + raise ApplicationError(msg) + + if fixed: + session.commit() + + +# @log_call +def _playlist_shift_rows( + session: Session, playlist_id: int, starting_row: int, shift_by: int +) -> None: + """ + Shift rows from starting_row by shift_by. If shift_by is +ve, shift rows + down; if -ve, shift them up. + """ + + session.execute( + update(PlaylistRows) + .where( + (PlaylistRows.playlist_id == playlist_id), + (PlaylistRows.row_number >= starting_row), + ) + .values(row_number=PlaylistRows.row_number + shift_by) + ) + + +# @log_call +def _playlists_where( + query: BinaryExpression | ColumnElement[bool], +) -> list[PlaylistDTO]: + """ + Return playlists selected by query + """ + + stmt = ( + select( + Playlists.favourite, + Playlists.is_template, + Playlists.playlist_id, + Playlists.name, + Playlists.open, + ) + .where(query) + .order_by(Playlists.tab) + ) + + results: list[PlaylistDTO] = [] + + with db.Session() as session: + records = session.execute(stmt).all() + for record in records: + dto = PlaylistDTO( + favourite=record.favourite, + is_template=record.is_template, + playlist_id=record.playlist_id, + name=record.name, + open=record.open, + ) + results.append(dto) + + return results + + +def playlists_all(): + """Return all playlists""" + + return _playlists_where(Playlists.playlist_id > 0) + + +# @log_call +def playlist_by_id(playlist_id: int) -> PlaylistDTO | None: + """ + Return playlist with specified id + """ + + playlist_list = _playlists_where(Playlists.playlist_id == playlist_id) + if not playlist_list: + return None + if len(playlist_list) > 1: + raise ApplicationError(f"Duplicate {playlist_id=}") + return playlist_list[0] + + +def playlist_copy(src_id: int, dst_id: int) -> None: + """Copy playlist entries""" + + with db.Session() as session: + src_rows = session.scalars( + select(PlaylistRows).where(PlaylistRows.playlist_id == src_id) + ).all() + + for plr in src_rows: + PlaylistRows( + session=session, + playlist_id=dst_id, + row_number=plr.row_number, + note=plr.note, + track_id=plr.track_id, + ) + + session.commit() + + +def playlists_closed() -> list[PlaylistDTO]: + """ + Return a list of closed playlists + """ + + return _playlists_where(Playlists.open.is_(False)) + + +# @log_call +def playlist_create( + name: str, template_id: int, as_template: bool = False +) -> PlaylistDTO: + """ + Create playlist and return DTO. + """ + + with db.Session() as session: + try: + playlist = Playlists(session, name, template_id) + playlist.is_template = as_template + playlist_id = playlist.playlist_id + session.commit() + except Exception: + raise ApplicationError("Can't create Playlist") + + if template_id != 0: + playlist_copy(template_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 playlist_delete(playlist_id: int) -> None: + """Delete playlist""" + + with db.Session() as session: + query = session.get(Playlists, playlist_id) + session.delete(query) + session.commit() + + +# @log_call +def playlist_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 + """ + + with db.Session() as session: + # Sanity check + _playlist_check_playlist(session, playlist_id, fix=False) + + # Make space for new row + _playlist_shift_rows( + session=session, + playlist_id=playlist_id, + starting_row=row_number, + shift_by=1, + ) + + playlist_row = PlaylistRows( + session=session, + playlist_id=playlist_id, + row_number=row_number, + note=note, + track_id=track_id, + ) + session.commit() + playlist_row_id = playlist_row.playlistrow_id + + # Sanity check + _playlist_check_playlist(session, playlist_id, fix=False) + + new_playlist_row = playlistrow_by_id(playlistrow_id=playlist_row_id) + if not new_playlist_row: + raise ApplicationError("Can't retrieve new playlist row") + + return new_playlist_row + + +# @log_call +def playlist_mark_status(playlist_id: int, open: bool) -> None: + """Mark playlist as open or closed""" + + with db.Session() as session: + session.execute( + update(Playlists) + .where(Playlists.playlist_id == playlist_id) + .values(open=open) + ) + + session.commit() + + +# @log_call +def playlist_move_rows( + from_rows: list[int], + from_playlist_id: int, + to_row: int, + to_playlist_id: int | None = None, +) -> None: + """ + Call helper function depending upon whether we are moving rows within + a playlist or between playlists. + """ + + # If to_playlist_id isn't specified, we're moving within the one + # playlist. + if to_playlist_id is None or to_playlist_id == from_playlist_id: + _playlist_move_rows_within_playlist(from_rows, from_playlist_id, to_row) + else: + _playlist_move_rows_between_playlists( + from_rows, from_playlist_id, to_row, to_playlist_id + ) + + +def _playlist_move_rows_between_playlists( + from_rows: list[int], + from_playlist_id: int, + to_row: int, + to_playlist_id: int, +) -> None: + """ + Move rows between playlists. + + Algorithm: + - Sanity check row numbers + - Resequence remaining row numbers + - Make space for moved rows + - Move the PENDING_MOVE rows back and fixup row numbers + - Sanity check row numbers + """ + + # Sanity check destination not being moved + if to_row in from_rows: + log.error(f"ds._playlist_move_rows_within_playlist: {to_row=} in {from_rows=}") + return + + with db.Session() as session: + # Sanity check row numbers + _playlist_check_playlist(session, from_playlist_id, fix=False) + _playlist_check_playlist(session, to_playlist_id, fix=False) + + # Make space in destination playlist + _playlist_shift_rows(session, to_playlist_id, to_row, len(from_rows)) + + # Update database + # Build a dictionary of changes to make + update_list: list[dict[str, int]] = [] + old_row_to_id = _playlist_rows_to_id(from_playlist_id) + next_row = to_row + + for from_row in from_rows: + plrid = old_row_to_id[from_row] + update_list.append({"playlistrow_id": plrid, "row_number": next_row}) + update_list.append({"playlistrow_id": plrid, "playlist_id": to_playlist_id}) + next_row += 1 + + session.execute(update(PlaylistRows), update_list) + session.commit() + + # Resequence row numbers in source + _playlist_check_playlist(session, from_playlist_id, fix=True) + # Sanity check destination + _playlist_check_playlist(session, from_playlist_id, fix=False) + + +def _playlist_rows_to_id(playlist_id: int) -> dict[int, int]: + """ + Return a dict of {row_number: playlistrow_id} for passed playlist + """ + + row_to_id = { + p.row_number: p.playlistrow_id for p in playlistrows_by_playlist(playlist_id) + } + + return row_to_id + + +# @log_call +def _playlist_move_rows_within_playlist( + from_rows: list[int], + from_playlist_id: int, + to_row: int, +) -> None: + """ + Move rows within playlists. + + Algorithm: + - Sanity checks + - Create a list of row numbers in the new order + - Update the database with the new order + - Sanity check row numbers + """ + + # Sanity check destination not being moved + if to_row in from_rows: + log.error(f"ds._playlist_move_rows_within_playlist: {to_row=} in {from_rows=}") + return + + with db.Session() as session: + # Sanity check row numbers + _playlist_check_playlist(session, from_playlist_id, fix=False) + + # Create a list showing the new order of rows in playlist + # Start with a list of rows excluding those to be moved + from_playlist_length = len(playlistrows_by_playlist(from_playlist_id)) + new_row_order = [a for a in range(from_playlist_length) if a not in from_rows] + # Insert the moved row numbers + try: + idx = new_row_order.index(to_row) + except ValueError: + raise ApplicationError(f"Can't find {to_row=} in {new_row_order=}") + new_row_order[idx:idx] = from_rows + + # Update database + # Build a dictionary of {old_row_number: new_row_number} where + # they differ + row_changes = {old: new for new, old in enumerate(new_row_order) if old != new} + # Build a dictionary of changes to make + update_list: list[dict[str, int]] = [] + old_row_to_id = _playlist_rows_to_id(from_playlist_id) + for old_row, new_row in row_changes.items(): + plrid = old_row_to_id[old_row] + update_list.append({"playlistrow_id": plrid, "row_number": new_row}) + + # Update database + session.execute(update(PlaylistRows), update_list) + session.commit() + + # Sanity check row numbers + _playlist_check_playlist(session, from_playlist_id, fix=False) + + +def playlists_open() -> list[PlaylistDTO]: + """ + Return a list of open playlists + """ + + return _playlists_where(Playlists.open.is_(True)) + + +def playlist_rename(playlist_id: int, new_name: str) -> None: + """ + Rename playlist + """ + + with db.Session() as session: + session.execute( + update(Playlists) + .where(Playlists.playlist_id == playlist_id) + .values(name=new_name) + ) + + session.commit() + + +def playlist_row_count(playlist_id: int) -> int: + """ + Return number of rows in playlist + """ + + with db.Session() as session: + count = session.scalar( + select(func.count()) + .select_from(PlaylistRows) + .where(PlaylistRows.playlist_id == playlist_id) + ) + + return count + + +def playlist_save_as_template(playlist_id: int, template_name: str) -> None: + """ + Save playlist as templated + """ + + new_template = playlist_create(template_name, 0, as_template=True) + + playlist_copy(playlist_id, new_template.playlist_id) + + +def playlists_templates_all() -> list[PlaylistDTO]: + """ + Return a list of playlist templates + """ + + return _playlists_where(Playlists.is_template.is_(True)) + + +def playlists_template_by_id(playlist_id: int) -> PlaylistDTO | None: + """ + Return a list of closed playlists + """ + + playlist_list = _playlists_where(Playlists.playlist_id == playlist_id) + + if not playlist_list: + return None + if len(playlist_list) > 1: + raise ApplicationError(f"Duplicate {playlist_id=}") + template = playlist_list[0] + if template.is_template is False: + raise ApplicationError(f"Playlist {playlist_id=} is not a template") + + return template + + +# @log_call +def playlist_update_row_numbers( + playlist_id: int, id_to_row_number: list[dict[int, int]] +) -> None: + """ + Update playlistrows rownumbers for passed playlistrow_ids + playlist_id is only needed for sanity checking + """ + + with db.Session() as session: + session.execute(update(PlaylistRows), id_to_row_number) + session.commit() + + # Sanity check + _playlist_check_playlist(session, playlist_id, fix=False) + + +# @log_call +def playlist_remove_comments(playlist_id: int, row_numbers: list[int]) -> None: + """ + Remove comments from rows in playlist + """ + + with db.Session() as session: + session.execute( + update(PlaylistRows) + .where( + PlaylistRows.playlist_id == playlist_id, + PlaylistRows.row_number.in_(row_numbers), + ) + .values(note="") + ) + session.commit() + + +# @log_call +def playlist_remove_rows(playlist_id: int, row_numbers: list[int]) -> None: + """ + Remove rows from playlist + + Delete from highest row back so that not yet deleted row numbers don't change. + """ + + with db.Session() as session: + for row_number in sorted(row_numbers, reverse=True): + session.execute( + delete(PlaylistRows).where( + PlaylistRows.playlist_id == playlist_id, + PlaylistRows.row_number == row_number, + ) + ) + # Fixup row number to remove gaps + _playlist_check_playlist(session, playlist_id, fix=True) + + +# @log_call +def playlist_save_tabs(playlist_id_to_tab: dict[int, int]) -> None: + """ + Save the tab numbers of the open playlists. + """ + + with db.Session() as session: + # Clear all existing tab numbers + session.execute( + update(Playlists) + .where(Playlists.playlist_id.in_(playlist_id_to_tab.keys())) + .values(tab=None) + ) + for playlist_id, tab in playlist_id_to_tab.items(): + session.execute( + update(Playlists) + .where(Playlists.playlist_id == playlist_id) + .values(tab=tab) + ) + session.commit() + + +# @log_call +def playlist_update_template_favourite(template_id: int, favourite: bool) -> None: + """Update template favourite""" + + with db.Session() as session: + session.execute( + update(Playlists) + .where(Playlists.playlist_id == template_id) + .values(favourite=favourite) + ) + session.commit() + + +# Playlist Rows + + +# @log_call +def playlistrow_by_id(playlistrow_id: int) -> PlaylistRowDTO | None: + """ + Return specific row DTO + """ + + with db.Session() as session: + record = ( + session.execute( + select(PlaylistRows).where( + PlaylistRows.playlistrow_id == playlistrow_id + ) + ) + .scalars() + .one_or_none() + ) + if not record: + return None + + track = None + if record.track_id: + track = track_by_id(record.track_id) + + dto = PlaylistRowDTO( + note=record.note, + played=record.played, + playlist_id=record.playlist_id, + playlistrow_id=record.playlistrow_id, + row_number=record.row_number, + track=track, + ) + + return dto + + +def playlistrows_by_playlist( + playlist_id: int, check_playlist_itegrity: bool = True +) -> list[PlaylistRowDTO]: + with db.Session() as session: + # TODO: would be good to be confident at removing this + if check_playlist_itegrity: + _playlist_check_playlist( + session=session, playlist_id=playlist_id, fix=False + ) + + records = session.scalars( + select(PlaylistRows) + .where(PlaylistRows.playlist_id == playlist_id) + .order_by(PlaylistRows.row_number) + ).all() + + dto_list = [] + for record in records: + track = None + if record.track_id: + track = track_by_id(record.track_id) + + dto = PlaylistRowDTO( + note=record.note, + played=record.played, + playlist_id=record.playlist_id, + playlistrow_id=record.playlistrow_id, + row_number=record.row_number, + track=track, + ) + + dto_list.append(dto) + + return dto_list + + +def playlistrow_update_note(playlistrow_id: int, note: str) -> PlaylistRowDTO: + """ + Update the note on a playlist row + """ + + with db.Session() as session: + plr = session.get(PlaylistRows, playlistrow_id) + + if not plr: + raise ApplicationError(f"Can't retrieve Playlistrow ({playlistrow_id=})") + + plr.note = note + + session.commit() + + new_plr = playlistrow_by_id(playlistrow_id) + if not new_plr: + raise ApplicationError(f"Can't retrieve new Playlistrow ({playlistrow_id=})") + + return new_plr + + +def playlistrow_played(playlistrow_id: int, status: bool) -> None: + """Update played status of row""" + + with db.Session() as session: + session.execute( + update(PlaylistRows) + .where(PlaylistRows.playlistrow_id == playlistrow_id) + .values(played=status) + ) + session.commit() + + +# Playdates +# @log_call +def playdates_get_last(track_id: int, limit: int = 5) -> str: + """ + Return the most recent 'limit' dates that this track has been played + as a text list + """ + + with db.Session() as session: + playdates = session.scalars( + Playdates.select() + .where(Playdates.track_id == track_id) + .order_by(Playdates.lastplayed.desc()) + .limit(limit) + ).all() + + return "
".join( + [ + a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) + for a in playdates + ] + ) + + +def playdates_update(track_id: int, when: dt.datetime | None = None) -> None: + """ + Update playdates for passed track + """ + + if not when: + when = dt.datetime.now() + + with db.Session() as session: + _ = Playdates(session, track_id, when) + + +def playdates_between_dates( + start: dt.datetime, end: dt.datetime | None = None +) -> list[PlaydatesDTO]: + """ + Return a list of PlaydateDTO objects from between times (until now if end is None) + """ + + if end is None: + end = dt.datetime.now() + + stmt = select( + Playdates.playdate_id, + Playdates.lastplayed, + Playdates.track_id, + Playdates.track, + ).where(Playdates.lastplayed >= start, Playdates.lastplayed <= end) + + results: list[PlaydatesDTO] = [] + + with db.Session() as session: + records = session.execute(stmt).all() + for record in records: + dto = PlaydatesDTO( + playdate_id=record.playdate_id, + lastplayed=record.lastplayed, + track_id=record.track_id, + artist=record.track.artist, + bitrate=record.track.bitrate, + duration=record.track.duration, + fade_at=record.track.fade_at, + intro=record.track.intro, + path=record.track.path, + silence_at=record.track.silence_at, + start_gap=record.track.start_gap, + title=record.track.title, + ) + results.append(dto) + + return results + + +# Queries +# @log_call +def _queries_where( + query: BinaryExpression | ColumnElement[bool], +) -> list[QueryDTO]: + """ + Return queries selected by query + """ + + results: list[QueryDTO] = [] + + with db.Session() as session: + records = session.scalars(select(Queries).where(query)).all() + for record in records: + dto = QueryDTO( + favourite=record.favourite, + filter=record.filter, + name=record.name, + query_id=record.query_id, + ) + results.append(dto) + + return results + + +def queries_all(favourites_only: bool = False) -> list[QueryDTO]: + """Return a list of all queries""" + + query = Queries.query_id > 0 + return _queries_where(query) + + +def query_by_id(query_id: int) -> QueryDTO | None: + """Return query""" + + query_list = _queries_where(Queries.query_id == query_id) + if not query_list: + return None + if len(query_list) > 1: + raise ApplicationError(f"Duplicate {query_id=}") + return query_list[0] + + +def query_create(name: str, filter: Filter) -> QueryDTO: + """ + Create a query and return the DTO + """ + + with db.Session() as session: + try: + query = Queries(session=session, name=name, filter=filter) + query_id = query.query_id + session.commit() + except Exception: + raise ApplicationError("Can't create Query") + + new_query = query_by_id(query_id) + if not new_query: + raise ApplicationError("Unable to create new query") + + return new_query + + +def query_delete(query_id: int) -> None: + """Delete query""" + + with db.Session() as session: + query = session.get(Queries, query_id) + session.delete(query) + session.commit() + + +def query_update_favourite(query_id: int, favourite: bool) -> None: + """Update query favourite""" + + with db.Session() as session: + session.execute( + update(Queries) + .where(Queries.query_id == query_id) + .values(favourite=favourite) + ) + session.commit() + + +def query_update_filter(query_id: int, filter: Filter) -> None: + """Update query filter""" + + with db.Session() as session: + session.execute( + update(Queries).where(Queries.query_id == query_id).values(filter=filter) + ) + session.commit() + + +def query_update_name(query_id: int, name: str) -> None: + """Update query name""" + + with db.Session() as session: + session.execute( + update(Queries).where(Queries.query_id == query_id).values(name=name) + ) + session.commit() + + +# Misc +def setting_get(name: str) -> int | None: + """ + Get int setting + """ + + with db.Session() as session: + record = ( + session.execute(select(Settings).where(Settings.name == name)) + .scalars() + .one_or_none() + ) + if not record: + return None + + return record.f_int + + +def setting_set(name: str, value: int) -> None: + """ + Add int setting + """ + + with db.Session() as session: + record = ( + session.execute(select(Settings).where(Settings.name == name)) + .scalars() + .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() + + +def db_name_get() -> str: + """Return database name""" + + with db.Session() as session: + if session.bind: + dbname = session.bind.engine.url.database + return dbname + return Config.DB_NOT_FOUND diff --git a/app/file_importer.py b/app/file_importer.py index 1abc3a5..c1ed6e9 100644 --- a/app/file_importer.py +++ b/app/file_importer.py @@ -4,7 +4,6 @@ from dataclasses import dataclass, field from fuzzywuzzy import fuzz # type: ignore import os.path import threading -from typing import Optional, Sequence import os import shutil @@ -32,19 +31,22 @@ from classes import ( MusicMusterSignals, singleton, Tags, + TrackDTO, ) from config import Config from helpers import ( audio_file_extension, file_is_unreadable, + get_all_track_metadata, + get_audio_metadata, get_tags, + normalise_track, show_OK, ) from log import log -from models import db, Tracks -from music_manager import track_sequence +from playlistrow import TrackSequence from playlistmodel import PlaylistModel -import helpers +import ds @dataclass @@ -68,7 +70,7 @@ class TrackFileData: destination_path: str = "" import_this_file: bool = False error: str = "" - file_path_to_remove: Optional[str] = None + file_path_to_remove: str | None = None track_id: int = 0 track_match_data: list[TrackMatchData] = field(default_factory=list) @@ -121,13 +123,7 @@ class FileImporter: # Get signals self.signals = MusicMusterSignals() - def _get_existing_tracks(self) -> Sequence[Tracks]: - """ - Return a list of all existing Tracks - """ - - with db.Session() as session: - return Tracks.get_all(session) + self.existing_tracks: list[TrackDTO] = [] def start(self) -> None: """ @@ -147,7 +143,7 @@ class FileImporter: # Refresh list of existing tracks as they may have been updated # by previous imports - self.existing_tracks = self._get_existing_tracks() + self.existing_tracks = ds.tracks_all() for infile in [ os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f) @@ -278,7 +274,7 @@ class FileImporter: artist_match=artist_score, title=existing_track.title, title_match=title_score, - track_id=existing_track.id, + track_id=existing_track.track_id, ) ) @@ -411,12 +407,14 @@ class FileImporter: else: tfd.destination_path = existing_track_path - def _get_existing_track(self, track_id: int) -> Tracks: + def _get_existing_track(self, track_id: int) -> TrackDTO: """ Lookup in existing track in the local cache and return it """ - existing_track_records = [a for a in self.existing_tracks if a.id == track_id] + existing_track_records = [ + a for a in self.existing_tracks if a.track_id == track_id + ] if len(existing_track_records) != 1: raise ApplicationError( f"Internal error in _get_existing_track: {existing_track_records=}" @@ -490,13 +488,12 @@ class FileImporter: # file). Check that because the path field in the database is # unique and so adding a duplicate will give a db integrity # error. - with db.Session() as session: - if Tracks.get_by_path(session, tfd.destination_path): - tfd.error = ( - "Importing a new track but destination path already exists " - f"in database ({tfd.destination_path})" - ) - return False + if ds.track_by_path(tfd.destination_path): + tfd.error = ( + "Importing a new track but destination path already exists " + f"in database ({tfd.destination_path})" + ) + return False # Check track_id if tfd.track_id < 0: @@ -618,7 +615,7 @@ class DoTrackImport(QThread): tags: Tags, destination_path: str, track_id: int, - file_path_to_remove: Optional[str] = None, + file_path_to_remove: str | None = None, ) -> None: """ Save parameters @@ -659,42 +656,20 @@ class DoTrackImport(QThread): # Move new file to destination shutil.move(self.import_file_path, self.destination_track_path) - with db.Session() as session: - if self.track_id == 0: - # Import new track - try: - track = Tracks( - session, - path=self.destination_track_path, - **self.tags._asdict(), - **self.audio_metadata._asdict(), - ) - except Exception as e: - self.signals.show_warning_signal.emit( - "Error importing track", str(e) - ) - return - else: - track = session.get(Tracks, self.track_id) - if track: - for key, value in self.tags._asdict().items(): - if hasattr(track, key): - setattr(track, key, value) - for key, value in self.audio_metadata._asdict().items(): - if hasattr(track, key): - setattr(track, key, value) - track.path = self.destination_track_path - else: - log.error(f"Unable to retrieve {self.track_id=}") - return - session.commit() + # Normalise + normalise_track(self.destination_track_path) - helpers.normalise_track(self.destination_track_path) + # Update databse + metadata = get_all_track_metadata(self.destination_track_path) + if self.track_id == 0: + track_dto = ds.track_create(metadata) + else: + track_dto = ds.track_update(self.track_id, metadata) - self.signals.status_message_signal.emit( - f"{os.path.basename(self.import_file_path)} imported", 10000 - ) - self.import_finished.emit(self.import_file_path, track.id) + self.signals.status_message_signal.emit( + f"{os.path.basename(self.import_file_path)} imported", 10000 + ) + self.import_finished.emit(self.import_file_path, track_dto.track_id) class PickMatch(QDialog): @@ -723,6 +698,7 @@ class PickMatch(QDialog): self.setWindowTitle("New or replace") layout = QVBoxLayout() + track_sequence = TrackSequence() # Add instructions instructions = ( diff --git a/app/helpers.py b/app/helpers.py index c710554..19a330a 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -21,10 +21,9 @@ from pydub.utils import mediainfo from tinytag import TinyTag, TinyTagException # type: ignore # App imports -from classes import AudioMetadata, ApplicationError, Tags +from classes import AudioMetadata, ApplicationError, Tags, TrackDTO from config import Config from log import log -from models import Tracks start_time_re = re.compile(r"@\d\d:\d\d") @@ -199,26 +198,32 @@ def get_relative_date( # Check parameters if past_date > reference_date: - return "get_relative_date() past_date is after relative_date" + raise ApplicationError("get_relative_date() past_date is after relative_date") - days: int - days_str: str - weeks: int - weeks_str: str + delta = reference_date - past_date + days = delta.days - weeks, days = divmod((reference_date.date() - past_date.date()).days, 7) - if weeks == days == 0: - # Same day so return time instead + if days == 0: return Config.LAST_PLAYED_TODAY_STRING + " " + past_date.strftime("%H:%M") - if weeks == 1: - weeks_str = "week" - else: - weeks_str = "weeks" - if days == 1: - days_str = "day" - else: - days_str = "days" - return f"{weeks} {weeks_str}, {days} {days_str}" + + elif days == 1: + return "(Yesterday)" + + years, days_remain_years = divmod(days, 365) + months, days_remain_months = divmod(days_remain_years, 30) + weeks, days_final = divmod(days_remain_months, 7) + + parts = [] + if years: + parts.append(f"{years}y") + if months: + parts.append(f"{months}m") + if weeks: + parts.append(f"{weeks}w") + if days_final: + parts.append(f"{days_final}d") + formatted = ", ".join(parts) + return formatted def get_tags(path: str) -> Tags: @@ -365,32 +370,6 @@ def normalise_track(path: str) -> None: os.remove(temp_path) -def remove_substring_case_insensitive(parent_string: str, substring: str) -> str: - """ - Remove all instances of substring from parent string, case insensitively - """ - - # Convert both strings to lowercase for case-insensitive comparison - lower_parent = parent_string.lower() - lower_substring = substring.lower() - - # Initialize the result string - result = parent_string - - # Continue removing the substring until it's no longer found - while lower_substring in lower_parent: - # Find the index of the substring - index = lower_parent.find(lower_substring) - - # Remove the substring - result = result[:index] + result[index + len(substring) :] - - # Update the lowercase versions - lower_parent = result.lower() - - return result - - def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None: # From https://docs.python.org/3/library/email.examples.html @@ -417,18 +396,6 @@ def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None: s.quit() -def set_track_metadata(track: Tracks) -> None: - """Set/update track metadata in database""" - - audio_metadata = get_audio_metadata(track.path) - tags = get_tags(track.path) - - for audio_key in AudioMetadata._fields: - setattr(track, audio_key, getattr(audio_metadata, audio_key)) - for tag_key in Tags._fields: - setattr(track, tag_key, getattr(tags, tag_key)) - - def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None: """Display a message to user""" diff --git a/app/log.py b/app/log.py index 7bb4653..c574b9b 100644 --- a/app/log.py +++ b/app/log.py @@ -80,17 +80,37 @@ log = logging.getLogger(Config.LOG_NAME) def handle_exception(exc_type, exc_value, exc_traceback): - error = str(exc_value) + """ + Inform user of exception + """ + + # Navigate to the inner stack frame + tb = exc_traceback + if not tb: + log.error(f"handle_excption({exc_type=}, {exc_value=}, {exc_traceback=}") + return + while tb.tb_next: + tb = tb.tb_next + + fname = os.path.basename(tb.tb_frame.f_code.co_filename) + lineno = tb.tb_lineno + msg = f"ApplicationError: {exc_value}\nat {fname}:{lineno}" + logmsg = f"ApplicationError: {exc_value} at {fname}:{lineno}" + if issubclass(exc_type, ApplicationError): - log.error(error) + log.error(logmsg) else: # Handle unexpected errors (log and display) - error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + error_msg = "".join( + traceback.format_exception(exc_type, exc_value, exc_traceback) + ) - print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg')) + print( + stackprinter.format(exc_value, suppressed_paths=["/.venv"], style="darkbg") + ) - msg = stackprinter.format(exc_value) - log.error(msg) + stack = stackprinter.format(exc_value) + log.error(stack) log.error(error_msg) print("Critical error:", error_msg) # Consider logging instead of print @@ -101,11 +121,10 @@ def handle_exception(exc_type, exc_value, exc_traceback): Config.ERRORS_TO, Config.ERRORS_FROM, "Exception (log_uncaught_exceptions) from musicmuster", - msg, + stack, ) if QApplication.instance() is not None: fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1] - msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}" QMessageBox.critical(None, "Application Error", msg) @@ -124,14 +143,15 @@ def log_call(func): args_repr = [truncate_large(a) for a in args] kwargs_repr = [f"{k}={truncate_large(v)}" for k, v in kwargs.items()] params_repr = ", ".join(args_repr + kwargs_repr) - log.debug(f"call {func.__name__}({params_repr})") + log.debug(f"call {func.__name__}({params_repr})", stacklevel=2) try: result = func(*args, **kwargs) - log.debug(f"return {func.__name__}: {truncate_large(result)}") + log.debug(f"return {func.__name__}: {truncate_large(result)}", stacklevel=2) return result except Exception as e: - log.debug(f"exception in {func.__name__}: {e}") + log.debug(f"exception in {func.__name__}: {e}", stacklevel=2) raise + return wrapper diff --git a/app/logging.yaml b/app/logging.yaml index fa07d8e..6da7dcb 100644 --- a/app/logging.yaml +++ b/app/logging.yaml @@ -4,7 +4,7 @@ disable_existing_loggers: True formatters: colored: (): colorlog.ColoredFormatter - format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(blue)s%(message)s" + format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(light_blue)s%(message)s" datefmt: "%H:%M:%S" syslog: format: "[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s" @@ -25,6 +25,7 @@ filters: musicmuster: - update_clocks - play_next + - show_signal handlers: stderr: diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 411788a..0000000 --- a/app/models.py +++ /dev/null @@ -1,873 +0,0 @@ -# Standard library imports -from __future__ import annotations - -from typing import Optional, Sequence -import datetime as dt -import os -import re -import sys - -# PyQt imports - -# Third party imports -from dogpile.cache import make_region -from dogpile.cache.api import NO_VALUE -from sqlalchemy import ( - bindparam, - delete, - func, - select, - text, - update, -) -from sqlalchemy.exc import IntegrityError, ProgrammingError -from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.orm import joinedload, selectinload -from sqlalchemy.orm.session import Session -from sqlalchemy.engine.row import RowMapping - -# App imports -from classes import ApplicationError, Filter -from config import Config -from dbmanager import DatabaseManager -import dbtables -from log import log - - -# Establish database connection -DATABASE_URL = os.environ.get("DATABASE_URL") -if DATABASE_URL is None: - raise ValueError("DATABASE_URL is undefined") -if "unittest" in sys.modules and "sqlite" not in DATABASE_URL: - raise ValueError("Unit tests running on non-Sqlite database") -db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db - -# Configure the cache region -cache_region = make_region().configure( - 'dogpile.cache.memory', # Use in-memory caching for now (switch to Redis if needed) - expiration_time=600 # Cache expires after 10 minutes -) - - -def run_sql(session: Session, sql: str) -> Sequence[RowMapping]: - """ - Run a sql string and return results - """ - - try: - return session.execute(text(sql)).mappings().all() - except ProgrammingError as e: - raise ApplicationError(e) - - -# Database classes -class NoteColours(dbtables.NoteColoursTable): - - def __init__( - self, - session: Session, - substring: str, - colour: str, - enabled: bool = True, - is_regex: bool = False, - is_casesensitive: bool = False, - order: Optional[int] = 0, - ) -> None: - self.substring = substring - self.colour = colour - self.enabled = enabled - self.is_regex = is_regex - self.is_casesensitive = is_casesensitive - self.order = order - - session.add(self) - session.commit() - - @classmethod - def get_all(cls, session: Session) -> Sequence["NoteColours"]: - """ - Return all records - """ - - cache_key = "note_colours_all" - cached_result = cache_region.get(cache_key) - - if cached_result is not NO_VALUE: - return cached_result - - # Query the database - result = session.scalars( - select(cls) - .where( - cls.enabled.is_(True), - ) - .order_by(cls.order) - ).all() - cache_region.set(cache_key, result) - - return result - - @staticmethod - def get_colour( - session: Session, text: str, foreground: bool = False - ) -> str: - """ - Parse text and return background (foreground if foreground==True) colour - string if matched, else None - - """ - - if not text: - return "" - - match = False - for rec in NoteColours.get_all(session): - if rec.is_regex: - flags = re.UNICODE - if not rec.is_casesensitive: - flags |= re.IGNORECASE - p = re.compile(rec.substring, flags) - if p.match(text): - match = True - else: - if rec.is_casesensitive: - if rec.substring in text: - match = True - else: - if rec.substring.lower() in text.lower(): - match = True - - if match: - if foreground: - return rec.foreground or "" - else: - return rec.colour - return "" - - @staticmethod - def invalidate_cache() -> None: - """Invalidate dogpile cache""" - - cache_region.delete("note_colours_all") - - -class Playdates(dbtables.PlaydatesTable): - def __init__( - self, session: Session, track_id: int, when: Optional[dt.datetime] = None - ) -> None: - """Record that track was played""" - - if not when: - self.lastplayed = dt.datetime.now() - else: - self.lastplayed = when - self.track_id = track_id - session.add(self) - session.commit() - - @staticmethod - def last_playdates( - session: Session, track_id: int, limit: int = 5 - ) -> Sequence["Playdates"]: - """ - Return a list of the last limit playdates for this track, sorted - latest to earliest. - """ - - return session.scalars( - Playdates.select() - .where(Playdates.track_id == track_id) - .order_by(Playdates.lastplayed.desc()) - .limit(limit) - ).all() - - @staticmethod - def last_played(session: Session, track_id: int) -> dt.datetime: - """Return datetime track last played or None""" - - last_played = session.execute( - select(Playdates.lastplayed) - .where(Playdates.track_id == track_id) - .order_by(Playdates.lastplayed.desc()) - .limit(1) - ).first() - - if last_played: - return last_played[0] - else: - # Should never be reached as we create record with a - # last_played value - return Config.EPOCH # pragma: no cover - - @staticmethod - def last_played_tracks(session: Session, limit: int = 5) -> Sequence["Playdates"]: - """ - Return a list of the last limit tracks played, sorted - earliest to latest. - """ - - return session.scalars( - Playdates.select().order_by(Playdates.lastplayed.desc()).limit(limit) - ).all() - - @staticmethod - def played_after(session: Session, since: dt.datetime) -> Sequence["Playdates"]: - """Return a list of Playdates objects since passed time""" - - return session.scalars( - select(Playdates) - .where(Playdates.lastplayed >= since) - .order_by(Playdates.lastplayed) - ).all() - - -class Playlists(dbtables.PlaylistsTable): - def __init__(self, session: Session, name: str, template_id: int) -> None: - """Create playlist with passed name""" - - self.name = name - self.last_used = dt.datetime.now() - session.add(self) - session.commit() - - # If a template is specified, copy from it - if template_id: - PlaylistRows.copy_playlist(session, template_id, self.id) - - @staticmethod - def clear_tabs(session: Session, playlist_ids: list[int]) -> None: - """ - Make all tab records NULL - """ - - session.execute( - update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None) - ) - - def close(self, session: Session) -> None: - """Mark playlist as unloaded""" - - self.open = False - session.commit() - - @classmethod - def get_all(cls, session: Session) -> Sequence["Playlists"]: - """Returns a list of all playlists ordered by last use""" - - return session.scalars( - select(cls) - .filter(cls.is_template.is_(False)) - .order_by(cls.last_used.desc()) - ).all() - - @classmethod - def get_all_templates(cls, session: Session) -> Sequence["Playlists"]: - """Returns a list of all templates ordered by name""" - - return session.scalars( - select(cls).where(cls.is_template.is_(True)).order_by(cls.name) - ).all() - - @classmethod - def get_favourite_templates(cls, session: Session) -> Sequence["Playlists"]: - """Returns a list of favourite templates ordered by name""" - - return session.scalars( - select(cls) - .where(cls.is_template.is_(True), cls.favourite.is_(True)) - .order_by(cls.name) - ).all() - - @classmethod - def get_closed(cls, session: Session) -> Sequence["Playlists"]: - """Returns a list of all closed playlists ordered by last use""" - - return session.scalars( - select(cls) - .filter( - cls.open.is_(False), - cls.is_template.is_(False), - ) - .order_by(cls.last_used.desc()) - ).all() - - @classmethod - def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]: - """ - Return a list of loaded playlists ordered by tab. - """ - - return session.scalars( - select(cls).where(cls.open.is_(True)).order_by(cls.tab) - ).all() - - def mark_open(self) -> None: - """Mark playlist as loaded and used now""" - - self.open = True - self.last_used = dt.datetime.now() - - @staticmethod - def name_is_available(session: Session, name: str) -> bool: - """ - Return True if no playlist of this name exists else false. - """ - - return ( - session.execute(select(Playlists).where(Playlists.name == name)).first() - is None - ) - - def rename(self, session: Session, new_name: str) -> None: - """ - Rename playlist - """ - - self.name = new_name - session.commit() - - @staticmethod - def save_as_template( - session: Session, playlist_id: int, template_name: str - ) -> None: - """Save passed playlist as new template""" - - template = Playlists(session, template_name, template_id=0) - if not template or not template.id: - return - - template.is_template = True - session.commit() - - PlaylistRows.copy_playlist(session, playlist_id, template.id) - - -class PlaylistRows(dbtables.PlaylistRowsTable): - def __init__( - self, - session: Session, - playlist_id: int, - row_number: int, - note: str = "", - track_id: Optional[int] = None, - ) -> None: - """Create PlaylistRows object""" - - self.playlist_id = playlist_id - self.track_id = track_id - self.row_number = row_number - self.note = note - session.add(self) - session.commit() - - def append_note(self, extra_note: str) -> None: - """Append passed note to any existing note""" - - current_note = self.note - if current_note: - self.note = current_note + "\n" + extra_note - else: - self.note = extra_note - - @staticmethod - def copy_playlist(session: Session, src_id: int, dst_id: int) -> None: - """Copy playlist entries""" - - src_rows = session.scalars( - select(PlaylistRows).filter(PlaylistRows.playlist_id == src_id) - ).all() - - for plr in src_rows: - PlaylistRows( - session=session, - playlist_id=dst_id, - row_number=plr.row_number, - note=plr.note, - track_id=plr.track_id, - ) - - @classmethod - def deep_row( - cls, session: Session, playlist_id: int, row_number: int - ) -> "PlaylistRows": - """ - Return a playlist row that includes full track and lastplayed data for - given playlist_id and row - """ - - stmt = ( - select(PlaylistRows) - .options(joinedload(cls.track)) - .where( - PlaylistRows.playlist_id == playlist_id, - PlaylistRows.row_number == row_number, - ) - # .options(joinedload(Tracks.playdates)) - ) - - return session.execute(stmt).unique().scalar_one() - - @staticmethod - def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None: - """ - Delete rows in given playlist that have a higher row number - than 'maxrow' - """ - - session.execute( - delete(PlaylistRows).where( - PlaylistRows.playlist_id == playlist_id, - PlaylistRows.row_number > maxrow, - ) - ) - session.commit() - - @staticmethod - def delete_row(session: Session, playlist_id: int, row_number: int) -> None: - """ - Delete passed row in given playlist. - """ - - session.execute( - delete(PlaylistRows).where( - PlaylistRows.playlist_id == playlist_id, - PlaylistRows.row_number == row_number, - ) - ) - - @staticmethod - def fixup_rownumbers(session: Session, playlist_id: int) -> None: - """ - Ensure the row numbers for passed playlist have no gaps - """ - - plrs = session.scalars( - select(PlaylistRows) - .where(PlaylistRows.playlist_id == playlist_id) - .order_by(PlaylistRows.row_number) - ).all() - - for i, plr in enumerate(plrs): - plr.row_number = i - - # Ensure new row numbers are available to the caller - session.commit() - - @classmethod - def plrids_to_plrs( - cls, session: Session, playlist_id: int, plr_ids: list[int] - ) -> Sequence["PlaylistRows"]: - """ - Take a list of PlaylistRows ids and return a list of corresponding - PlaylistRows objects - """ - - plrs = session.scalars( - select(cls) - .where(cls.playlist_id == playlist_id, cls.id.in_(plr_ids)) - .order_by(cls.row_number) - ).all() - - return plrs - - @staticmethod - def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]: - """Return the last used row for playlist, or None if no rows""" - - return session.execute( - select(func.max(PlaylistRows.row_number)).where( - PlaylistRows.playlist_id == playlist_id - ) - ).scalar_one() - - @staticmethod - def get_track_plr( - session: Session, track_id: int, playlist_id: int - ) -> Optional["PlaylistRows"]: - """Return first matching PlaylistRows object or None""" - - return session.scalars( - select(PlaylistRows) - .where( - PlaylistRows.track_id == track_id, - PlaylistRows.playlist_id == playlist_id, - ) - .limit(1) - ).first() - - @classmethod - def get_played_rows( - cls, session: Session, playlist_id: int - ) -> Sequence["PlaylistRows"]: - """ - For passed playlist, return a list of rows that - have been played. - """ - - plrs = session.scalars( - select(cls) - .where(cls.playlist_id == playlist_id, cls.played.is_(True)) - .order_by(cls.row_number) - ).all() - - return plrs - - @classmethod - def get_playlist_rows( - cls, session: Session, playlist_id: int - ) -> Sequence["PlaylistRows"]: - """ - For passed playlist, return a list of rows. - """ - - stmt = ( - select(cls) - .where(cls.playlist_id == playlist_id) - .options(selectinload(cls.track)) - .order_by(cls.row_number) - ) - plrs = session.execute(stmt).scalars().all() - - return plrs - - @classmethod - def get_rows_with_tracks( - cls, - session: Session, - playlist_id: int, - ) -> Sequence["PlaylistRows"]: - """ - For passed playlist, return a list of rows that - contain tracks - """ - - query = select(cls).where( - cls.playlist_id == playlist_id, cls.track_id.is_not(None) - ) - plrs = session.scalars((query).order_by(cls.row_number)).all() - - return plrs - - @classmethod - def get_unplayed_rows( - cls, session: Session, playlist_id: int - ) -> Sequence["PlaylistRows"]: - """ - For passed playlist, return a list of playlist rows that - have not been played. - """ - - plrs = session.scalars( - select(cls) - .where( - cls.playlist_id == playlist_id, - cls.track_id.is_not(None), - cls.played.is_(False), - ) - .order_by(cls.row_number) - ).all() - - return plrs - - @classmethod - def insert_row( - cls, - session: Session, - playlist_id: int, - new_row_number: int, - note: str = "", - track_id: Optional[int] = None, - ) -> "PlaylistRows": - cls.move_rows_down(session, playlist_id, new_row_number, 1) - return cls( - session, - playlist_id=playlist_id, - row_number=new_row_number, - note=note, - track_id=track_id, - ) - - @staticmethod - def move_rows_down( - session: Session, playlist_id: int, starting_row: int, move_by: int - ) -> None: - """ - Create space to insert move_by additional rows by incremented row - number from starting_row to end of playlist - """ - - log.debug(f"(move_rows_down({playlist_id=}, {starting_row=}, {move_by=}") - - session.execute( - update(PlaylistRows) - .where( - (PlaylistRows.playlist_id == playlist_id), - (PlaylistRows.row_number >= starting_row), - ) - .values(row_number=PlaylistRows.row_number + move_by) - ) - - @staticmethod - def update_plr_row_numbers( - session: Session, - playlist_id: int, - sqla_map: list[dict[str, int]], - ) -> None: - """ - Take a {plrid: row_number} dictionary and update the row numbers accordingly - """ - - # Update database. Ref: - # https://docs.sqlalchemy.org/en/20/tutorial/data_update.html#the-update-sql-expression-construct - stmt = ( - update(PlaylistRows) - .where( - PlaylistRows.playlist_id == playlist_id, - PlaylistRows.id == bindparam("playlistrow_id"), - ) - .values(row_number=bindparam("row_number")) - ) - session.connection().execute(stmt, sqla_map) - - -class Queries(dbtables.QueriesTable): - def __init__( - self, - session: Session, - name: str, - filter: dbtables.Filter, - favourite: bool = False, - ) -> None: - """Create new query""" - - self.name = name - self.filter = filter - self.favourite = favourite - session.add(self) - session.commit() - - @classmethod - def get_all(cls, session: Session) -> Sequence["Queries"]: - """Returns a list of all queries ordered by name""" - - return session.scalars(select(cls).order_by(cls.name)).all() - - @classmethod - def get_favourites(cls, session: Session) -> Sequence["Queries"]: - """Returns a list of favourite queries ordered by name""" - - return session.scalars( - select(cls).where(cls.favourite.is_(True)).order_by(cls.name) - ).all() - - -class Settings(dbtables.SettingsTable): - def __init__(self, session: Session, name: str) -> None: - self.name = name - session.add(self) - session.commit() - - @classmethod - def get_setting(cls, session: Session, name: str) -> "Settings": - """Get existing setting or return new setting record""" - - try: - return session.execute(select(cls).where(cls.name == name)).scalar_one() - - except NoResultFound: - return Settings(session, name) - - -class Tracks(dbtables.TracksTable): - def __init__( - self, - session: Session, - path: str, - title: str, - artist: str, - duration: int, - start_gap: int, - fade_at: int, - silence_at: int, - bitrate: int, - ) -> None: - self.path = path - self.title = title - self.artist = artist - self.bitrate = bitrate - self.duration = duration - self.start_gap = start_gap - self.fade_at = fade_at - self.silence_at = silence_at - - try: - session.add(self) - session.commit() - except IntegrityError as error: - session.rollback() - log.error(f"Error ({error=}) importing track ({path=})") - raise ValueError(error) - - @classmethod - def get_all(cls, session: Session) -> Sequence["Tracks"]: - """Return a list of all tracks""" - - return session.scalars(select(cls)).unique().all() - - @classmethod - def all_tracks_indexed_by_id(cls, session: Session) -> dict[int, Tracks]: - """ - Return a dictionary of all tracks, keyed by title - """ - - result: dict[int, Tracks] = {} - - for track in cls.get_all(session): - result[track.id] = track - - return result - - @classmethod - def exact_title_and_artist( - cls, session: Session, title: str, artist: str - ) -> Sequence["Tracks"]: - """ - Search for exact but case-insensitive match of title and artist - """ - - return ( - session.scalars( - select(cls) - .where(cls.title.ilike(title), cls.artist.ilike(artist)) - .order_by(cls.title) - ) - .unique() - .all() - ) - - @classmethod - def get_filtered_tracks( - cls, session: Session, filter: Filter - ) -> Sequence["Tracks"]: - """ - Return tracks matching filter - """ - - query = select(cls) - - # Path specification - if filter.path: - if filter.path_type == "contains": - query = query.where(cls.path.ilike(f"%{filter.path}%")) - elif filter.path_type == "excluding": - query = query.where(cls.path.notilike(f"%{filter.path}%")) - else: - raise ApplicationError(f"Can't process filter path ({filter=})") - - # Duration specification - seconds_duration = filter.duration_number - if filter.duration_unit == Config.FILTER_DURATION_MINUTES: - seconds_duration *= 60 - elif filter.duration_unit != Config.FILTER_DURATION_SECONDS: - raise ApplicationError(f"Can't process filter duration ({filter=})") - - if filter.duration_type == Config.FILTER_DURATION_LONGER: - query = query.where(cls.duration >= seconds_duration) - elif filter.duration_unit == Config.FILTER_DURATION_SHORTER: - query = query.where(cls.duration <= seconds_duration) - else: - raise ApplicationError(f"Can't process filter duration type ({filter=})") - - # Process comparator - if filter.last_played_comparator == Config.FILTER_PLAYED_COMPARATOR_NEVER: - # Select tracks that have never been played - query = query.outerjoin(Playdates, cls.id == Playdates.track_id).where( - Playdates.id.is_(None) - ) - else: - # Last played specification - now = dt.datetime.now() - # Set sensible default, and correct for Config.FILTER_PLAYED_COMPARATOR_ANYTIME - before = now - # If not ANYTIME, set 'before' appropriates - if filter.last_played_comparator != Config.FILTER_PLAYED_COMPARATOR_ANYTIME: - if filter.last_played_unit == Config.FILTER_PLAYED_DAYS: - before = now - dt.timedelta(days=filter.last_played_number) - elif filter.last_played_unit == Config.FILTER_PLAYED_WEEKS: - before = now - dt.timedelta(days=7 * filter.last_played_number) - elif filter.last_played_unit == Config.FILTER_PLAYED_MONTHS: - before = now - dt.timedelta(days=30 * filter.last_played_number) - elif filter.last_played_unit == Config.FILTER_PLAYED_YEARS: - before = now - dt.timedelta(days=365 * filter.last_played_number) - - subquery = ( - select( - Playdates.track_id, - func.max(Playdates.lastplayed).label("max_last_played"), - ) - .group_by(Playdates.track_id) - .subquery() - ) - query = query.join(subquery, Tracks.id == subquery.c.track_id).where( - subquery.c.max_last_played < before - ) - - records = session.scalars(query).unique().all() - - return records - - @classmethod - def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]: - """ - Return track with passed path, or None. - """ - - try: - return ( - session.execute(select(Tracks).where(Tracks.path == path)) - .unique() - .scalar_one() - ) - except NoResultFound: - return None - - @classmethod - def search_artists(cls, session: Session, text: str) -> Sequence["Tracks"]: - """ - Search case-insenstively for artists containing str - - The query performs an outer join with 'joinedload' to populate the results - from the Playdates table at the same time. unique() needed; see - https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading - """ - - return ( - session.scalars( - select(cls) - .options(joinedload(Tracks.playdates)) - .where(cls.artist.ilike(f"%{text}%")) - .order_by(cls.title) - ) - .unique() - .all() - ) - - @classmethod - def search_titles(cls, session: Session, text: str) -> Sequence["Tracks"]: - """ - Search case-insenstively for titles containing str - - The query performs an outer join with 'joinedload' to populate the results - from the Playdates table at the same time. unique() needed; see - https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading - """ - return ( - session.scalars( - select(cls) - .options(joinedload(Tracks.playdates)) - .where(cls.title.like(f"{text}%")) - .order_by(cls.title) - ) - .unique() - .all() - ) diff --git a/app/music_manager.py b/app/music_manager.py index 7d14f64..345bec7 100644 --- a/app/music_manager.py +++ b/app/music_manager.py @@ -2,165 +2,25 @@ from __future__ import annotations import datetime as dt +import threading from time import sleep -from typing import Optional + # Third party imports # import line_profiler -import numpy as np -import pyqtgraph as pg # type: ignore -from sqlalchemy.orm.session import Session import vlc # type: ignore # PyQt imports from PyQt6.QtCore import ( pyqtSignal, - QObject, QThread, ) -from pyqtgraph import PlotWidget -from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore -from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem # type: ignore # App imports -from classes import ApplicationError, MusicMusterSignals +from classes import MusicMusterSignals, singleton from config import Config import helpers from log import log -from models import PlaylistRows -from vlcmanager import VLCManager - -# Define the VLC callback function type -# import ctypes -# import platform -# VLC logging is very noisy so comment out unless needed -# VLC_LOG_CB = ctypes.CFUNCTYPE( -# None, -# ctypes.c_void_p, -# ctypes.c_int, -# ctypes.c_void_p, -# ctypes.c_char_p, -# ctypes.c_void_p, -# ) - -# # Determine the correct C library for vsnprintf based on the platform -# if platform.system() == "Windows": -# libc = ctypes.CDLL("msvcrt") -# elif platform.system() == "Linux": -# libc = ctypes.CDLL("libc.so.6") -# elif platform.system() == "Darwin": # macOS -# libc = ctypes.CDLL("libc.dylib") -# else: -# raise OSError("Unsupported operating system") - -# # Define the vsnprintf function -# libc.vsnprintf.argtypes = [ -# ctypes.c_char_p, -# ctypes.c_size_t, -# ctypes.c_char_p, -# ctypes.c_void_p, -# ] -# 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, - rat: RowAndTrack, - track_path: str, - track_fade_at: int, - track_silence_at: int, - ) -> None: - super().__init__() - self.rat = rat - 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.rat.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): @@ -193,71 +53,39 @@ class _FadeTrack(QThread): ) sleep(1 / Config.FADEOUT_STEPS_PER_SECOND) + self.player.stop() self.finished.emit() -# TODO can we move this into the _Music class? -vlc_instance = VLCManager().vlc_instance +@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 -class _Music: +class Music: """ Manage the playing of music tracks """ def __init__(self, name: str) -> None: - vlc_instance.set_user_agent(name, name) - self.player: Optional[vlc.MediaPlayer] = None 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.vlc_event_manager: vlc.EventManager | None = None self.max_volume: int = Config.VLC_VOLUME_DEFAULT - self.start_dt: Optional[dt.datetime] = None - - # Set up logging - # self._set_vlc_log() - - # VLC logging very noisy so comment out unless needed - # @VLC_LOG_CB - # def log_callback(data, level, ctx, fmt, args): - # try: - # # Create a ctypes string buffer to hold the formatted message - # buf = ctypes.create_string_buffer(1024) - - # # Use vsnprintf to format the string with the va_list - # libc.vsnprintf(buf, len(buf), fmt, args) - - # # Decode the formatted message - # message = buf.value.decode("utf-8", errors="replace") - # log.debug("VLC: " + message) - # except Exception as e: - # log.error(f"Error in VLC log callback: {e}") - - # def _set_vlc_log(self): - # try: - # vlc.libvlc_log_set(vlc_instance, self.log_callback, None) - # log.debug("VLC logging set up successfully") - # except Exception as e: - # log.error(f"Failed to set up VLC logging: {e}") - - def adjust_by_ms(self, ms: int) -> None: - """Move player position by ms milliseconds""" - - if not self.player: - return - - elapsed_ms = self.get_playtime() - position = self.get_position() - if not position: - position = 0.0 - new_position = max(0.0, position + ((position * ms) / elapsed_ms)) - self.set_position(new_position) - # Adjus start time so elapsed time calculations are correct - if new_position == 0: - self.start_dt = dt.datetime.now() - else: - if self.start_dt: - self.start_dt -= dt.timedelta(milliseconds=ms) - else: - self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms) + self.start_dt: dt.datetime | None = None + self.signals = MusicMusterSignals() + self.end_of_track_signalled = False def fade(self, fade_seconds: int) -> None: """ @@ -273,6 +101,8 @@ class _Music: if not self.player.get_position() > 0 and self.player.is_playing(): return + self.emit_signal_track_ended() + self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds) self.fader_worker.finished.connect(self.player.release) self.fader_worker.start() @@ -292,11 +122,11 @@ class _Music: elapsed_seconds = (now - self.start_dt).total_seconds() return int(elapsed_seconds * 1000) - def get_position(self) -> Optional[float]: + def get_position(self) -> float: """Return current position""" if not self.player: - return None + return 0.0 return self.player.get_position() def is_playing(self) -> bool: @@ -317,11 +147,13 @@ class _Music: < dt.timedelta(microseconds=Config.PLAY_SETTLE) ) + # @log_call def play( self, path: str, start_time: dt.datetime, - position: Optional[float] = None, + playlist_id: int, + position: float | None = None, ) -> None: """ Start playing the track at path. @@ -332,13 +164,13 @@ class _Music: the start time is the same """ - log.debug(f"Music[{self.name}].play({path=}, {position=}") + self.playlist_id = playlist_id if helpers.file_is_unreadable(path): log.error(f"play({path}): path not readable") - return None + return - self.player = vlc.MediaPlayer(vlc_instance, path) + self.player = vlc.MediaPlayer(self.vlc_instance, path) if self.player is None: log.error(f"_Music:play: failed to create MediaPlayer ({path=})") helpers.show_warning( @@ -346,6 +178,14 @@ class _Music: ) return + self.events = self.player.event_manager() + self.events.event_attach( + vlc.EventType.MediaPlayerEndReached, self.track_end_event_handler + ) + self.events.event_attach( + vlc.EventType.MediaPlayerStopped, self.track_end_event_handler + ) + _ = self.player.play() self.set_volume(self.max_volume) @@ -353,21 +193,6 @@ class _Music: self.player.set_position(position) self.start_dt = start_time - # For as-yet unknown reasons. sometimes the volume gets - # reset to zero within 200mS or so of starting play. This - # only happened since moving to Debian 12, which uses - # Pipewire for sound (which may be irrelevant). - # It has been known for the volume to need correcting more - # than once in the first 200mS. - # Update August 2024: This no longer seems to be an issue - # for _ in range(3): - # if self.player: - # volume = self.player.audio_get_volume() - # if volume < Config.VLC_VOLUME_DEFAULT: - # self.set_volume(Config.VLC_VOLUME_DEFAULT) - # log.error(f"Reset from {volume=}") - # sleep(0.1) - def set_position(self, position: float) -> None: """ Set player position @@ -376,9 +201,7 @@ class _Music: if self.player: self.player.set_position(position) - def set_volume( - self, volume: Optional[int] = None, set_default: bool = True - ) -> None: + def set_volume(self, volume: int | None = None, set_default: bool = True) -> None: """Set maximum volume used for player""" if not self.player: @@ -396,13 +219,29 @@ class _Music: # reset to zero within 200mS or so of starting play. This # only happened since moving to Debian 12, which uses # Pipewire for sound (which may be irrelevant). + # Update 19 April 2025: this may no longer be occuring for _ in range(3): current_volume = self.player.audio_get_volume() if current_volume < volume: self.player.audio_set_volume(volume) - log.debug(f"Reset from {volume=}") + log.debug(f"Volume reset from {volume=}") sleep(0.1) + def emit_signal_track_ended(self) -> None: + """ + Multiple parts of the Music class can signal that the track has + ended. Handle them all here to ensure that only one such signal + is raised. Make this thead safe. + """ + + lock = threading.Lock() + + with lock: + if self.end_of_track_signalled: + return + self.signals.signal_track_ended.emit(self.playlist_id) + self.end_of_track_signalled = True + def stop(self) -> None: """Immediately stop playing""" @@ -417,333 +256,12 @@ class _Music: self.player.stop() self.player.release() self.player = None + self.emit_signal_track_ended() - -class RowAndTrack: - """ - Object to manage playlist rows and tracks. - """ - - def __init__(self, playlist_row: PlaylistRows) -> None: + def track_end_event_handler(self, event: vlc.Event) -> None: """ - Initialises data structure. - - The passed PlaylistRows object will include a Tracks object if this - row has a track. + Handler for MediaPlayerEndReached """ - # Collect playlistrow data - self.note = playlist_row.note - self.played = playlist_row.played - self.playlist_id = playlist_row.playlist_id - self.playlistrow_id = playlist_row.id - self.row_number = playlist_row.row_number - self.track_id = playlist_row.track_id - - # Playlist display data - self.row_fg: Optional[str] = None - self.row_bg: Optional[str] = None - self.note_fg: Optional[str] = None - self.note_bg: Optional[str] = None - - # Collect track data if there's a track - if playlist_row.track_id: - self.artist = playlist_row.track.artist - self.bitrate = playlist_row.track.bitrate - self.duration = playlist_row.track.duration - self.fade_at = playlist_row.track.fade_at - self.intro = playlist_row.track.intro - if playlist_row.track.playdates: - self.lastplayed = max( - [a.lastplayed for a in playlist_row.track.playdates] - ) - else: - self.lastplayed = Config.EPOCH - self.path = playlist_row.track.path - self.silence_at = playlist_row.track.silence_at - self.start_gap = playlist_row.track.start_gap - self.title = playlist_row.track.title - else: - self.artist = "" - self.bitrate = 0 - self.duration = 0 - self.fade_at = 0 - self.intro = None - self.lastplayed = Config.EPOCH - self.path = "" - self.silence_at = 0 - self.start_gap = 0 - self.title = "" - - # Track playing data - self.end_of_track_signalled: bool = False - self.end_time: Optional[dt.datetime] = None - self.fade_graph: Optional[_FadeCurve] = None - self.fade_graph_start_updates: Optional[dt.datetime] = None - self.resume_marker: Optional[float] = 0.0 - self.forecast_end_time: Optional[dt.datetime] = None - self.forecast_start_time: Optional[dt.datetime] = None - self.start_time: Optional[dt.datetime] = None - - # Other object initialisation - self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME) - self.signals = MusicMusterSignals() - - def __repr__(self) -> str: - return ( - f"" - ) - - 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 create_fade_graph(self) -> None: - """ - Initialise and add FadeCurve in a thread as it's slow - """ - - self.fadecurve_thread = QThread() - self.worker = _AddFadeCurve( - self, - track_path=self.path, - track_fade_at=self.fade_at, - track_silence_at=self.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 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 move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None: - """ - Rewind player by ms milliseconds - """ - - self.music.adjust_by_ms(ms * -1) - - def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None: - """ - Rewind player by ms milliseconds - """ - - self.music.adjust_by_ms(ms) - - 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 restart(self) -> None: - """ - Restart player - """ - - self.music.adjust_by_ms(self.time_playing() * -1) - - 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 - """ - - 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[RowAndTrack] = None - current: Optional[RowAndTrack] = None - previous: Optional[RowAndTrack] = None - - def set_next(self, rat: Optional[RowAndTrack]) -> 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.next.create_fade_graph() - - -track_sequence = TrackSequence() + log.debug("track_end_event_handler() called") + self.emit_signal_track_ended() diff --git a/app/musicmuster.py b/app/musicmuster.py index 1059ddd..671aed8 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -2,10 +2,11 @@ # Standard library imports from __future__ import annotations +from functools import partial from slugify import slugify # type: ignore -from typing import Callable, Optional +from typing import Any, Callable import argparse -from dataclasses import dataclass +from dataclasses import dataclass, field import datetime as dt import os import subprocess @@ -59,7 +60,6 @@ from PyQt6.QtWidgets import ( # Third party imports # import line_profiler from pygame import mixer -from sqlalchemy.orm.session import Session import stackprinter # type: ignore # App imports @@ -67,41 +67,90 @@ from audacity_controller import AudacityController from classes import ( ApplicationError, Filter, + InsertTrack, MusicMusterSignals, + PlaylistDTO, + QueryDTO, + SelectedRows, TrackInfo, ) + from config import Config -from dialogs import TrackSelectDialog +from dialogs import TrackInsertDialog from file_importer import FileImporter +from helpers import file_is_unreadable, get_name from log import log, log_call from helpers import ask_yes_no, file_is_unreadable, get_name, show_warning -from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks -from music_manager import RowAndTrack, track_sequence from playlistmodel import PlaylistModel, PlaylistProxyModel +from playlistrow import PlaylistRow, TrackSequence from playlists import PlaylistTab from querylistmodel import QuerylistModel -from ui import icons_rc # noqa F401 from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore +from ui import icons_rc # noqa F401 +from ui.main_window_footer_ui import Ui_FooterSection # type: ignore from ui.main_window_header_ui import Ui_HeaderSection # type: ignore from ui.main_window_playlist_ui import Ui_PlaylistSection # type: ignore -from ui.main_window_footer_ui import Ui_FooterSection # type: ignore - from utilities import check_db, update_bitrates +import ds import helpers +class SignalMonitor: + def __init__(self): + self.signals = MusicMusterSignals() + + self.signals.enable_escape_signal.connect( + partial(self.show_signal, "enable_escape_signal ") + ) + self.signals.resize_rows_signal.connect( + partial(self.show_signal, "resize_rows_signal ") + ) + self.signals.signal_add_track_to_header.connect( + partial(self.show_signal, "signal_add_track_to_header ") + ) + self.signals.signal_begin_insert_rows.connect( + partial(self.show_signal, "signal_begin_insert_rows ") + ) + self.signals.signal_end_insert_rows.connect( + partial(self.show_signal, "signal_end_insert_rows ") + ) + self.signals.signal_insert_track.connect( + partial(self.show_signal, "signal_insert_track ") + ) + self.signals.signal_playlist_selected_rows.connect( + partial(self.show_signal, "signal_playlist_selected_rows ") + ) + self.signals.signal_set_next_row.connect( + partial(self.show_signal, "signal_set_next_row ") + ) + self.signals.signal_set_next_track.connect( + partial(self.show_signal, "signal_set_next_track ") + ) + self.signals.signal_track_started.connect( + partial(self.show_signal, "signal_track_started ") + ) + # span_cells_signal is very noisy + # self.signals.span_cells_signal.connect( + # partial(self.show_signal, "span_cells_signal ") + # ) + self.signals.status_message_signal.connect( + partial(self.show_signal, "status_message_signal ") + ) + self.signals.signal_track_ended.connect( + partial(self.show_signal, "signal_track_ended ") + ) + + def show_signal(self, name: str, *args: Any) -> None: + log.debug(f"SIGNAL: {name=}, args={args}") + + +@dataclass class Current: base_model: PlaylistModel proxy_model: PlaylistProxyModel - playlist_id: int = 0 - selected_rows: list[int] = [] - - def __repr__(self): - return ( - f"" - ) + playlist_id: int + selected_row_numbers: list[int] = field(default_factory=list) class DownloadCSV(QDialog): @@ -456,10 +505,9 @@ class ManageQueries(ItemlistManager): Delete / edit queries """ - def __init__(self, session: Session, musicmuster: Window) -> None: + def __init__(self, musicmuster: Window) -> None: super().__init__() - self.session = session self.musicmuster = musicmuster self.refresh_table() self.exec() @@ -472,17 +520,20 @@ class ManageQueries(ItemlistManager): # Build a list of queries query_list: list[ItemlistItem] = [] - for query in Queries.get_all(self.session): + for query in ds.queries_all(): query_list.append( - ItemlistItem(name=query.name, id=query.id, favourite=query.favourite) + ItemlistItem( + name=query.name, id=query.query_id, favourite=query.favourite + ) ) self.populate_table(query_list) + # @log_call def delete_item(self, query_id: int) -> None: """delete query""" - query = self.session.get(Queries, query_id) + query = ds.query_by_id(query_id) if not query: raise ApplicationError( f"manage_template.delete({query_id=}) can't load query" @@ -491,25 +542,23 @@ class ManageQueries(ItemlistManager): "Delete query", f"Delete query '{query.name}': " "Are you sure?", ): - log.debug(f"manage_queries: delete {query=}") - self.session.delete(query) - self.session.commit() + ds.query_delete(query_id) self.refresh_table() - def _edit_item(self, query: Queries) -> None: + def _edit_item(self, query: QueryDTO) -> None: """Edit query""" dlg = FilterDialog(query.name, query.filter) if dlg.exec(): - query.filter = dlg.filter - query.name = dlg.name_text.text() - self.session.commit() + ds.query_update_filter + (query.query_id, dlg.filter) + ds.query_update_name(query.query_id, dlg.name_text.text()) def edit_item(self, query_id: int) -> None: """Edit query""" - query = self.session.get(Queries, query_id) + query = ds.query_by_id(query_id) if not query: raise ApplicationError( f"manage_template.edit_item({query_id=}) can't load query" @@ -519,11 +568,7 @@ class ManageQueries(ItemlistManager): def toggle_favourite(self, query_id: int, favourite: bool) -> None: """Mark query as (not) favourite""" - query = self.session.get(Queries, query_id) - if not query: - return - query.favourite = favourite - self.session.commit() + ds.query_update_favourite(query_id, favourite) def new_item(self) -> None: """Create new query""" @@ -532,24 +577,21 @@ class ManageQueries(ItemlistManager): if not query_name: return - query = Queries(self.session, query_name, Filter()) + query = ds.query_create(query_name, Filter()) self._edit_item(query) self.refresh_table() def rename_item(self, query_id: int) -> None: """rename query""" - query = self.session.get(Queries, query_id) + query = ds.query_by_id(query_id) if not query: - raise ApplicationError( - f"manage_template.delete({query_id=}) can't load query" - ) + raise ApplicationError(f"Can't load query ({query_id=})") new_name = get_name(prompt="New query name", default=query.name) if not new_name: return - query.name = new_name - self.session.commit() + ds.query_update_name(query_id, new_name) self.change_text(query_id, new_name) @@ -559,10 +601,9 @@ class ManageTemplates(ItemlistManager): Delete / edit templates """ - def __init__(self, session: Session, musicmuster: Window) -> None: + def __init__(self, musicmuster: Window) -> None: super().__init__() - self.session = session self.musicmuster = musicmuster self.refresh_table() self.exec() @@ -575,19 +616,22 @@ class ManageTemplates(ItemlistManager): # Build a list of templates template_list: list[ItemlistItem] = [] - for template in Playlists.get_all_templates(self.session): + for template in ds.playlists_templates_all(): template_list.append( ItemlistItem( - name=template.name, id=template.id, favourite=template.favourite + name=template.name, + id=template.playlist_id, + favourite=template.favourite, ) ) self.populate_table(template_list) + # @log_call def delete_item(self, template_id: int) -> None: """delete template""" - template = self.session.get(Playlists, template_id) + template = ds.playlists_template_by_id(template_id) if not template: raise ApplicationError( f"manage_template.delete({template_id=}) can't load template" @@ -607,14 +651,12 @@ class ManageTemplates(ItemlistManager): else: self.musicmuster.playlist_section.tabPlaylist.removeTab(open_idx) - log.debug(f"manage_templates: delete {template=}") - self.session.delete(template) - self.session.commit() + ds.playlist_delete(template.playlist_id) def edit_item(self, template_id: int) -> None: """Edit template""" - template = self.session.get(Playlists, template_id) + template = ds.playlists_template_by_id(template_id) if not template: raise ApplicationError( f"manage_template.edit({template_id=}) can't load template" @@ -626,11 +668,7 @@ class ManageTemplates(ItemlistManager): def toggle_favourite(self, template_id: int, favourite: bool) -> None: """Mark template as (not) favourite""" - template = self.session.get(Playlists, template_id) - if not template: - return - template.favourite = favourite - self.session.commit() + ds.playlist_update_template_favourite(template_id, favourite) def new_item( self, @@ -639,22 +677,20 @@ class ManageTemplates(ItemlistManager): # Get base template template_id = self.musicmuster.solicit_template_to_use( - self.session, template_prompt="New template based upon:" + template_prompt="New template based upon:" ) if template_id is None: return # Get new template name name = self.musicmuster.get_playlist_name( - self.session, default="", prompt="New template name:" + default="", prompt="New template name:" ) if not name: return # Create playlist for template and mark is as a template - template = self.musicmuster._create_playlist(self.session, name, template_id) - template.is_template = True - self.session.commit() + template = ds.playlist_create(name, template_id, as_template=True) # Open it for editing self.musicmuster._open_playlist(template, is_template=True) @@ -662,28 +698,14 @@ class ManageTemplates(ItemlistManager): def rename_item(self, template_id: int) -> None: """rename template""" - template = self.session.get(Playlists, template_id) + template = ds.playlist_by_id(template_id) if not template: raise ApplicationError( f"manage_template.delete({template_id=}) can't load template" ) - new_name = self.musicmuster.get_playlist_name(self.session, template.name) - if not new_name: - return - - template.name = new_name - self.session.commit() - - self.change_text(template_id, new_name) - - -@dataclass -class ItemlistManagerCallbacks: - delete: Callable[[int], None] - edit: Callable[[int], None] - favourite: Callable[[int, bool], None] - new_item: Callable[[], None] - rename: Callable[[int], Optional[str]] + new_name = self.musicmuster.get_playlist_name(template.name) + if new_name: + ds.playlist_rename(template_id, new_name) class PreviewManager: @@ -693,10 +715,10 @@ class PreviewManager: def __init__(self) -> None: mixer.init() - self.intro: Optional[int] = None + self.intro: int | None = None self.path: str = "" - self.row_number: Optional[int] = None - self.start_time: Optional[dt.datetime] = None + self.row_number: int | None = None + self.start_time: dt.datetime | None = None self.track_id: int = 0 def back(self, ms: int) -> None: @@ -791,18 +813,19 @@ class PreviewManager: class QueryDialog(QDialog): """Dialog box to handle selecting track from a query""" - def __init__(self, session: Session, default: int = 0) -> None: + def __init__(self, playlist_id: int, default: int = 0) -> None: super().__init__() - self.session = session + self.playlist_id = playlist_id self.default = default + self.signals = MusicMusterSignals() # Build a list of (query-name, playlist-id) tuples self.selected_tracks: list[int] = [] self.query_list: list[tuple[str, int]] = [] self.query_list.append((Config.NO_QUERY_NAME, 0)) - for query in Queries.get_all(self.session): - self.query_list.append((query.name, query.id)) + for query in ds.queries_all(): + self.query_list.append((query.name, query.query_id)) self.setWindowTitle("Query Selector") @@ -884,6 +907,13 @@ class QueryDialog(QDialog): def add_tracks_clicked(self): self.selected_tracks = self.table_view.model().get_selected_track_ids() + + # new_row_number = self.current_row_or_end() + # base_model = self.current.base_model + for track_id in self.selected_tracks: + insert_track_data = InsertTrack(self.playlist_id, track_id, note="") + self.signals.signal_insert_track.emit(insert_track_data) + self.accept() def cancel_clicked(self): @@ -919,8 +949,7 @@ class QueryDialog(QDialog): querylist_y=self.y(), ) for name, value in attributes_to_save.items(): - record = Settings.get_setting(self.session, name) - record.f_int = value + ds.setting_set(name, value) header = self.table_view.horizontalHeader() if header is None: @@ -930,10 +959,7 @@ class QueryDialog(QDialog): return for column_number in range(column_count - 1): attr_name = f"querylist_col_{column_number}_width" - record = Settings.get_setting(self.session, attr_name) - record.f_int = self.table_view.columnWidth(column_number) - - self.session.commit() + ds.setting_set(attr_name, self.table_view.columnWidth(column_number)) def _column_resize(self, column_number: int, _old: int, _new: int) -> None: """ @@ -960,14 +986,14 @@ class QueryDialog(QDialog): Called when user selects query """ - # Get query id + # Get query query_id = self.combo_box.currentData() - query = self.session.get(Queries, query_id) + query = ds.query_by_id(query_id) if not query: return # Create model - base_model = QuerylistModel(self.session, query.filter) + base_model = QuerylistModel(query.filter) # Create table self.table_view.setModel(base_model) @@ -984,10 +1010,10 @@ class QueryDialog(QDialog): def set_window_size(self) -> None: """Set window sizes""" - x = Settings.get_setting(self.session, "querylist_x").f_int or 100 - y = Settings.get_setting(self.session, "querylist_y").f_int or 100 - width = Settings.get_setting(self.session, "querylist_width").f_int or 100 - height = Settings.get_setting(self.session, "querylist_height").f_int or 100 + x = ds.setting_get("querylist_x") or 100 + y = ds.setting_get("querylist_y") or 100 + width = ds.setting_get("querylist_width") or 100 + height = ds.setting_get("querylist_height") or 100 self.setGeometry(x, y, width, height) def set_column_sizes(self) -> None: @@ -1003,17 +1029,12 @@ class QueryDialog(QDialog): # Last column is set to stretch so ignore it here for column_number in range(column_count - 1): attr_name = f"querylist_col_{column_number}_width" - record = Settings.get_setting(self.session, attr_name) - if record.f_int is not None: - self.table_view.setColumnWidth(column_number, record.f_int) - else: - self.table_view.setColumnWidth( - column_number, Config.DEFAULT_COLUMN_WIDTH - ) + width = ds.setting_get(attr_name) or Config.DEFAULT_COLUMN_WIDTH + self.table_view.setColumnWidth(column_number, width) class SelectPlaylistDialog(QDialog): - def __init__(self, parent=None, playlists=None, session=None): + def __init__(self, parent=None, playlists=None): super().__init__() if playlists is None: @@ -1023,13 +1044,10 @@ class SelectPlaylistDialog(QDialog): self.ui.lstPlaylists.itemDoubleClicked.connect(self.list_doubleclick) self.ui.buttonBox.accepted.connect(self.open) self.ui.buttonBox.rejected.connect(self.close) - self.session = session self.playlist = None - record = Settings.get_setting(self.session, "select_playlist_dialog_width") - width = record.f_int or 800 - record = Settings.get_setting(self.session, "select_playlist_dialog_height") - height = record.f_int or 600 + width = ds.setting_get("select_playlist_dialog_width") or 800 + height = ds.setting_get("select_playlist_dialog_height") or 800 self.resize(width, height) for playlist in playlists: @@ -1039,13 +1057,8 @@ class SelectPlaylistDialog(QDialog): self.ui.lstPlaylists.addItem(p) def __del__(self): # review - record = Settings.get_setting(self.session, "select_playlist_dialog_height") - record.f_int = self.height() - - record = Settings.get_setting(self.session, "select_playlist_dialog_width") - record.f_int = self.width() - - self.session.commit() + ds.setting_set("select_playlist_dialog_height", self.height()) + ds.setting_set("select_playlist_dialog_width", self.width()) def list_doubleclick(self, entry): # review self.playlist = entry.data(Qt.ItemDataRole.UserRole) @@ -1058,13 +1071,19 @@ class SelectPlaylistDialog(QDialog): self.accept() +@dataclass +class MoveSource: + model: PlaylistModel + rows: list[int] + + class TemplateSelectorDialog(QDialog): """ Class to manage user selection of template """ def __init__( - self, templates: list[tuple[str, int]], template_prompt: Optional[str] + self, templates: list[tuple[str, int]], template_prompt: str | None ) -> None: super().__init__() self.templates = templates @@ -1139,7 +1158,7 @@ class FooterSection(QWidget, Ui_FooterSection): class Window(QMainWindow): def __init__( - self, parent: Optional[QWidget] = None, *args: list, **kwargs: dict + self, parent: QWidget | None = None, *args: list, **kwargs: dict ) -> None: super().__init__(parent) @@ -1156,45 +1175,48 @@ class Window(QMainWindow): layout.addWidget(self.playlist_section) layout.addWidget(self.footer_section) - self.setWindowTitle(Config.MAIN_WINDOW_TITLE) - # Add menu bar - self.create_menu_bar() - - self.timer10: QTimer = QTimer() - self.timer100: QTimer = QTimer() - self.timer500: QTimer = QTimer() - self.timer1000: QTimer = QTimer() - - self.set_main_window_size() - self.lblSumPlaytime = QLabel("") - self.statusbar = self.statusBar() - if self.statusbar: - self.statusbar.addPermanentWidget(self.lblSumPlaytime) - self.txtSearch = QLineEdit() - self.txtSearch.setHidden(True) - self.statusbar.addWidget(self.txtSearch) - self.hide_played_tracks = False - self.preview_manager = PreviewManager() - self.footer_section.widgetFadeVolume.hideAxis("bottom") self.footer_section.widgetFadeVolume.hideAxis("left") self.footer_section.widgetFadeVolume.setDefaultPadding(0) self.footer_section.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND) - self.move_source_rows: Optional[list[int]] = None - self.move_source_model: Optional[PlaylistModel] = None + self.setWindowTitle(Config.MAIN_WINDOW_TITLE) - self.disable_selection_timing = False - self.clock_counter = 0 + # Add menu bar + self.create_menu_bar() + + # Configure main window + self.set_main_window_size() + self.lblSumPlaytime = QLabel("") + self.statusbar = self.statusBar() + if not self.statusbar: + raise ApplicationError("Can't create status bar") + self.statusbar.addPermanentWidget(self.lblSumPlaytime) + self.txtSearch = QLineEdit() + self.txtSearch.setHidden(True) + self.statusbar.addWidget(self.txtSearch) + self.hide_played_tracks = False + + # Timers + self.timer10: QTimer = QTimer() + self.timer100: QTimer = QTimer() + self.timer500: QTimer = QTimer() + self.timer1000: QTimer = QTimer() self.timer10.start(10) self.timer100.start(100) self.timer500.start(500) self.timer1000.start(1000) + + # Misc + self.preview_manager = PreviewManager() + self.move_source: MoveSource | None = None + self.disable_selection_timing = False + self.catch_return_key = False + self.importer: FileImporter | None = None + self.current: Current | None = None + self.track_sequence = TrackSequence() self.signals = MusicMusterSignals() self.connect_signals_slots() - self.catch_return_key = False - self.importer: Optional[FileImporter] = None - self.current = Current() webbrowser.register( "browser", @@ -1206,8 +1228,11 @@ class Window(QMainWindow): self.action_quicklog = QShortcut(QKeySequence("Ctrl+L"), self) self.action_quicklog.activated.connect(self.quicklog) + # Optionally print signals + self.signal_monitor = SignalMonitor() + + # Load playlists self.load_last_playlists() - self.stop_autoplay = False # Set up for Audacity try: @@ -1218,64 +1243,56 @@ class Window(QMainWindow): # # # # # # # # # # Overrides # # # # # # # # # # - def closeEvent(self, event: Optional[QCloseEvent]) -> None: + def closeEvent(self, event: QCloseEvent | None) -> None: """Handle attempt to close main window""" if not event: return # 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.music.is_playing() + ): event.ignore() helpers.show_warning( self, "Track playing", "Can't close application while track is playing" ) else: - with db.Session() as session: - # Save tab number of open playlists - open_playlist_ids: dict[int, int] = {} - for idx in range(self.playlist_section.tabPlaylist.count()): - open_playlist_ids[ - self.playlist_section.tabPlaylist.widget(idx).playlist_id - ] = idx - Playlists.clear_tabs(session, list(open_playlist_ids.keys())) - for playlist_id, idx in open_playlist_ids.items(): - playlist = session.get(Playlists, playlist_id) - if playlist: - log.debug(f"Set {playlist=} tab to {idx=}") - playlist.tab = idx + # Save tab number of open playlists + playlist_id_to_tab: dict[int, int] = {} + for idx in range(self.playlist_section.tabPlaylist.count()): + playlist_id_to_tab[ + self.playlist_section.tabPlaylist.widget(idx).playlist_id + ] = idx + ds.playlist_save_tabs(playlist_id_to_tab) - # Save window attributes - attributes_to_save = dict( - mainwindow_height=self.height(), - mainwindow_width=self.width(), - mainwindow_x=self.x(), - mainwindow_y=self.y(), - active_tab=self.playlist_section.tabPlaylist.currentIndex(), - ) - for name, value in attributes_to_save.items(): - record = Settings.get_setting(session, name) - record.f_int = value - - session.commit() + # Save window attributes + attributes_to_save = dict( + mainwindow_height=self.height(), + mainwindow_width=self.width(), + mainwindow_x=self.x(), + mainwindow_y=self.y(), + active_index=self.playlist_section.tabPlaylist.currentIndex(), + ) + for name, value in attributes_to_save.items(): + ds.setting_set(name, value) event.accept() # # # # # # # # # # Internal utility functions # # # # # # # # # # - def active_base_model(self) -> PlaylistModel: - return self.current.base_model - - def active_tab(self) -> PlaylistTab: + def _active_tab(self) -> PlaylistTab: return self.playlist_section.tabPlaylist.currentWidget() # # # # # # # # # # Menu functions # # # # # # # # # # def create_action( - self, text: str, handler: Callable, shortcut: Optional[str] = None + self, text: str, handler: Callable, shortcut: str | None = None ) -> QAction: """ - Helper function to create an action, bind it to a method, and set a shortcut if provided. + Helper function for menu creation. Create an action, bind it to a + method, and set a shortcut if provided. """ action = QAction(text, self) @@ -1328,6 +1345,73 @@ class Window(QMainWindow): menu.addAction(action) + def get_new_playlist_dynamic_submenu_items( + self, + ) -> list[dict[str, str | int | bool]]: + """ + Return dynamically generated menu items, in this case + templates marked as favourite from which to generate a + new playlist. + + The handler is to call create_playlist_from_template with a dict + of arguments. + """ + + submenu_items: list[dict[str, str | int | bool]] = [ + { + "text": "Show all", + "handler": "create_playlist_from_template", + "args": 0, + }, + { + "separator": True, + }, + ] + templates = ds.playlists_templates_all() + for template in templates: + submenu_items.append( + { + "text": template.name, + "handler": "create_playlist_from_template", + "args": template.playlist_id, + } + ) + + return submenu_items + + def get_query_dynamic_submenu_items( + self, + ) -> list[dict[str, str | int | bool]]: + """ + Return dynamically generated menu items, in this case + templates marked as favourite from which to generate a + new playlist. + + The handler is to call show_query with a query_id. + """ + + submenu_items: list[dict[str, str | int | bool]] = [ + { + "text": "Show all", + "handler": "show_query", + "args": 0, + }, + { + "separator": True, + }, + ] + queries = ds.queries_all(favourites_only=True) + for query in queries: + submenu_items.append( + { + "text": query.name, + "handler": "show_query", + "args": query.query_id, + } + ) + + return submenu_items + def populate_dynamic_submenu(self): """Dynamically populates submenus when they are selected.""" submenu = self.sender() # Get the submenu that triggered the event @@ -1351,132 +1435,24 @@ class Window(QMainWindow): if handler: # Use a lambda to pass arguments to the function - action.triggered.connect(lambda _, h=handler, a=args: h(*a)) + action.triggered.connect(lambda _, h=handler, a=args: h(a)) submenu.addAction(action) break - def get_new_playlist_dynamic_submenu_items( - self, - ) -> list[dict[str, str | tuple[Session, int] | bool]]: - """ - Return dynamically generated menu items, in this case - templates marked as favourite from which to generate a - new playlist. - - The handler is to call create_playlist with a session - and template_id. - """ - - with db.Session() as session: - submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ - { - "text": "Show all", - "handler": "create_playlist_from_template", - "args": (session, 0), - }, - { - "separator": True, - }, - ] - templates = Playlists.get_favourite_templates(session) - for template in templates: - submenu_items.append( - { - "text": template.name, - "handler": "create_playlist_from_template", - "args": ( - session, - template.id, - ), - } - ) - - return submenu_items - - def get_query_dynamic_submenu_items( - self, - ) -> list[dict[str, str | tuple[Session, int] | bool]]: - """ - Return dynamically generated menu items, in this case - templates marked as favourite from which to generate a - new playlist. - - The handler is to call show_query with a session - and query_id. - """ - - with db.Session() as session: - submenu_items: list[dict[str, str | tuple[Session, int] | bool]] = [ - { - "text": "Show all", - "handler": "show_query", - "args": (session, 0), - }, - { - "separator": True, - }, - ] - queries = Queries.get_favourites(session) - for query in queries: - submenu_items.append( - { - "text": query.name, - "handler": "show_query", - "args": ( - session, - query.id, - ), - } - ) - - return submenu_items - - def show_query(self, session: Session, query_id: int) -> None: - """ - Show query dialog with query_id selected - """ - - # Keep a reference else it will be gc'd - self.query_dialog = QueryDialog(session, query_id) - if self.query_dialog.exec(): - new_row_number = self.current_row_or_end() - base_model = self.current.base_model - for track_id in self.query_dialog.selected_tracks: - # Check whether track is already in playlist - move_existing = False - existing_prd = 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 move_existing and existing_prd: - base_model.move_track_add_note(new_row_number, existing_prd, note="") - else: - base_model.insert_row(new_row_number, track_id) - - new_row_number += 1 - # # # # # # # # # # Playlist management functions # # # # # # # # # # - def _create_playlist( - self, session: Session, name: str, template_id: int - ) -> Playlists: + # @log_call + def _create_playlist(self, name: str, template_id: int) -> PlaylistDTO: """ Create a playlist in the database, populate it from the template - if template_id > 0, and return the Playlists object. + if template_id > 0, and return the PlaylistDTO object. """ - log.debug(f" _create_playlist({name=}, {template_id=})") + return ds.playlist_create(name, template_id) - return Playlists(session, name, template_id) - - @log_call - def _open_playlist(self, playlist: Playlists, is_template: bool = False) -> int: + # @log_call + def _open_playlist(self, playlist: PlaylistDTO, is_template: bool = False) -> int: """ With passed playlist: - create models @@ -1486,10 +1462,8 @@ class Window(QMainWindow): return: tab index """ - log.debug(f" _open_playlist({playlist=}, {is_template=})") - # Create base model and proxy model - base_model = PlaylistModel(playlist.id, is_template) + base_model = PlaylistModel(playlist.playlist_id, is_template) proxy_model = PlaylistProxyModel() proxy_model.setSourceModel(base_model) @@ -1498,7 +1472,7 @@ class Window(QMainWindow): idx = self.playlist_section.tabPlaylist.addTab(playlist_tab, playlist.name) # Mark playlist as open - playlist.mark_open() + ds.playlist_mark_status(playlist.playlist_id, open=True) # Switch to new tab self.playlist_section.tabPlaylist.setCurrentIndex(idx) @@ -1506,92 +1480,93 @@ class Window(QMainWindow): return idx - def create_playlist_from_template(self, session: Session, template_id: int) -> None: + # @log_call + def create_playlist_from_template(self, template_id: int) -> None: """ - Prompt for new playlist name and create from passed template_id + Prompt for new playlist name and create from passed template_id. """ if template_id == 0: # Show all templates - selected_template_id = self.solicit_template_to_use(session) + selected_template_id = self.solicit_template_to_use() if selected_template_id is None: return - else: - template_id = selected_template_id + template_id = selected_template_id - playlist_name = self.get_playlist_name(session) + playlist_name = self.get_playlist_name() if not playlist_name: return - playlist = self._create_playlist(session, playlist_name, template_id) - self._open_playlist(playlist) - session.commit() + _ = ds.playlist_create(playlist_name, template_id) - def delete_playlist(self) -> None: + # @log_call + def delete_playlist(self, checked: bool = False) -> None: """ - Delete current playlist + Delete current playlist. checked paramater passed by menu system + but unused. """ - with db.Session() as session: - playlist_id = self.current.playlist_id - playlist = session.get(Playlists, playlist_id) - if playlist: - if helpers.ask_yes_no( - "Delete playlist", - f"Delete playlist '{playlist.name}': " "Are you sure?", - ): - if self.close_playlist_tab(): - session.delete(playlist) - session.commit() - else: - log.error("Failed to retrieve playlist") + if self.current is None: + return - def open_existing_playlist(self) -> None: + playlist = ds.playlist_by_id(self.current.playlist_id) + if playlist: + if helpers.ask_yes_no( + "Delete playlist", + f"Delete playlist '{playlist.name}': " "Are you sure?", + ): + if self.close_playlist_tab(): + ds.playlist_delete(self.current.playlist_id) + else: + log.error("Failed to retrieve playlist") + + def open_existing_playlist(self, checked: bool = False) -> None: """Open existing playlist""" - with db.Session() as session: - playlists = Playlists.get_closed(session) - dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) - dlg.exec() - playlist = dlg.playlist - if playlist: - self._open_playlist(playlist) - session.commit() + playlists = ds.playlists_closed() + dlg = SelectPlaylistDialog(self, playlists=playlists) + dlg.exec() + playlist = dlg.playlist + if playlist: + self._open_playlist(playlist) - def save_as_template(self) -> None: + def save_as_template(self, checked: bool = False) -> None: """Save current playlist as template""" - with db.Session() as session: - template_names = [a.name for a in Playlists.get_all_templates(session)] + if self.current is None: + return - while True: - # Get name for new template - dlg = QInputDialog(self) - dlg.setInputMode(QInputDialog.InputMode.TextInput) - dlg.setLabelText("Template name:") - dlg.resize(500, 100) - ok = dlg.exec() - if not ok: - return + template_names = [a.name for a in ds.playlists_templates_all()] - template_name = dlg.textValue() - if template_name not in template_names: - break - helpers.show_warning( - self, "Duplicate template", "Template name already in use" - ) - Playlists.save_as_template(session, self.current.playlist_id, template_name) - session.commit() - helpers.show_OK("Template", "Template saved", self) + while True: + # Get name for new template + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.InputMode.TextInput) + dlg.setLabelText("Template name:") + dlg.resize(500, 100) + ok = dlg.exec() + if not ok: + return + + template_name = dlg.textValue() + if template_name not in template_names: + break + helpers.show_warning( + self, "Duplicate template", "Template name already in use" + ) + ds.playlist_save_as_template(self.current.playlist_id, template_name) + helpers.show_OK("Template", "Template saved", self) def get_playlist_name( - self, session: Session, default: str = "", prompt: str = "Playlist name:" - ) -> Optional[str]: + self, default: str = "", prompt: str = "Playlist name:" + ) -> str | None: """Get a name from the user""" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) dlg.setLabelText(prompt) + all_playlist_names = [a.name for a in ds.playlists_all()] + while True: if default: dlg.setTextValue(default) @@ -1599,7 +1574,7 @@ class Window(QMainWindow): ok = dlg.exec() if ok: proposed_name = dlg.textValue() - if Playlists.name_is_available(session, proposed_name): + if proposed_name not in all_playlist_names: return proposed_name else: helpers.show_warning( @@ -1611,53 +1586,61 @@ class Window(QMainWindow): else: return None - def solicit_template_to_use( - self, session: Session, template_prompt: Optional[str] = None - ) -> Optional[int]: + def solicit_template_to_use(self, template_prompt: str | None = None) -> int | None: """ - Have user select a template. Return the template.id, or None if they cancel. - template_id of zero means don't use a template. + Have user select a template. Return the template.playlist_id, or + None if they cancel. template_id of zero means don't use a + template. """ template_name_id_list: list[tuple[str, int]] = [] template_name_id_list.append((Config.NO_TEMPLATE_NAME, 0)) - with db.Session() as session: - for template in Playlists.get_all_templates(session): - template_name_id_list.append((template.name, template.id)) + for template in ds.playlists_templates_all(): + template_name_id_list.append((template.name, template.playlist_id)) - dlg = TemplateSelectorDialog(template_name_id_list, template_prompt) - if not dlg.exec() or dlg.selected_id is None: - return None # User cancelled + dlg = TemplateSelectorDialog(template_name_id_list, template_prompt) + if not dlg.exec() or dlg.selected_id is None: + return None # User cancelled - return dlg.selected_id + return dlg.selected_id # # # # # # # # # # Manage templates and queries # # # # # # # # # # - def manage_queries_wrapper(self): + def manage_queries_wrapper(self, checked: bool = False) -> None: """ Simply instantiate the manage_queries class """ - with db.Session() as session: - _ = ManageQueries(session, self) + _ = ManageQueries(self) - def manage_templates_wrapper(self): + def manage_templates_wrapper(self, checked: bool = False) -> None: """ - Simply instantiate the manage_queries class + Simply instantiate the manage_templates class """ - with db.Session() as session: - _ = ManageTemplates(session, self) + _ = ManageTemplates(self) + + def show_query(self, query_id: int) -> None: + """ + Show query dialog with query_id selected + """ + + if self.current is None: + return + + # Keep a reference else it will be gc'd + self.query_dialog = QueryDialog(self.current.playlist_id, query_id) + self.query_dialog.exec() # # # # # # # # # # Miscellaneous functions # # # # # # # # # # - def select_duplicate_rows(self) -> None: + def select_duplicate_rows(self, checked: bool = False) -> None: """Call playlist to select duplicate rows""" - self.active_tab().select_duplicate_rows() + self._active_tab().select_duplicate_rows() - def about(self) -> None: + def about(self, checked: bool = False) -> None: """Get git tag and database name""" try: @@ -1667,9 +1650,7 @@ class Window(QMainWindow): except subprocess.CalledProcessError as exc_info: git_tag = str(exc_info.output) - with db.Session() as session: - if session.bind: - dbname = session.bind.engine.url.database + dbname = ds.db_name_get() QMessageBox.information( self, @@ -1683,20 +1664,20 @@ class Window(QMainWindow): Clear next track """ - track_sequence.set_next(None) - self.update_headers() + self.track_sequence.set_next(None) + self.signals.signal_set_next_track.emit(None) - def clear_selection(self) -> None: + def clear_selection(self, checked: bool = False) -> None: """Clear row selection""" # Unselect any selected rows - if self.active_tab(): - self.active_tab().clear_selection() + if self._active_tab(): + self._active_tab().clear_selection() # Clear the search bar self.search_playlist_clear() - def close_playlist_tab(self) -> bool: + def close_playlist_tab(self, checked: bool = False) -> bool: """ Close active playlist tab, called by menu item """ @@ -1716,8 +1697,8 @@ class Window(QMainWindow): ).playlist_id # Don't close current track playlist - if track_sequence.current is not None: - current_track_playlist_id = track_sequence.current.playlist_id + if self.track_sequence.current is not None: + current_track_playlist_id = self.track_sequence.current.playlist_id if current_track_playlist_id: if closing_tab_playlist_id == current_track_playlist_id: helpers.show_OK( @@ -1726,8 +1707,8 @@ class Window(QMainWindow): return False # Don't close next track playlist - if track_sequence.next is not None: - next_track_playlist_id = track_sequence.next.playlist_id + if self.track_sequence.next is not None: + next_track_playlist_id = self.track_sequence.next.playlist_id if next_track_playlist_id: if closing_tab_playlist_id == next_track_playlist_id: helpers.show_OK( @@ -1735,11 +1716,8 @@ class Window(QMainWindow): ) return False - # Record playlist as closed and update remaining playlist tabs - with db.Session() as session: - playlist = session.get(Playlists, closing_tab_playlist_id) - if playlist: - playlist.close(session) + # Record playlist as closed + ds.playlist_mark_status(closing_tab_playlist_id, open=False) # Close playlist and remove tab self.playlist_section.tabPlaylist.widget(tab_index).close() @@ -1768,20 +1746,22 @@ class Window(QMainWindow): self.tabBar = self.playlist_section.tabPlaylist.tabBar() self.txtSearch.textChanged.connect(self.search_playlist_text_changed) - self.signals.enable_escape_signal.connect(self.enable_escape) - self.signals.next_track_changed_signal.connect(self.update_headers) - self.signals.status_message_signal.connect(self.show_status_message) + self.signals.enable_escape_signal.connect(self.enable_escape_signal_handler) self.signals.show_warning_signal.connect(self.show_warning) - self.signals.track_ended_signal.connect(self.end_of_track_actions) + self.signals.signal_next_track_changed.connect(self.next_track_changed_handler) + self.signals.signal_set_next_track.connect(self.set_next_track_handler) + self.signals.status_message_signal.connect(self.show_status_message) + self.signals.signal_track_ended.connect(self.track_ended_handler) + self.signals.signal_playlist_selected_rows.connect( + self.playlist_selected_rows_handler + ) self.timer10.timeout.connect(self.tick_10ms) self.timer500.timeout.connect(self.tick_500ms) self.timer100.timeout.connect(self.tick_100ms) self.timer1000.timeout.connect(self.tick_1000ms) - self.signals.search_songfacts_signal.connect(self.open_songfacts_browser) - self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser) - + # @log_call def current_row_or_end(self) -> int: """ If a row or rows are selected, return the row number of the first @@ -1789,18 +1769,25 @@ class Window(QMainWindow): of the playlist. """ - if self.current.selected_rows: - return self.current.selected_rows[0] + # TODO should be able to have the model handle row depending on + # how current_row_or_end is used + if self.current is None: + return 0 # hack, but should never be called without self.current set + + if self.current.selected_row_numbers: + return self.current.selected_row_numbers[0] + if not self.current.base_model: + return 0 # hack, but mostly there WILL be a current model return self.current.base_model.rowCount() - def debug(self): + def debug(self, checked: bool = False) -> None: """Invoke debugger""" import ipdb # type: ignore ipdb.set_trace() - def download_played_tracks(self) -> None: + def download_played_tracks(self, checked: bool = False) -> None: """Download a CSV of played tracks""" dlg = DownloadCSV(self) @@ -1821,17 +1808,19 @@ class Window(QMainWindow): path += ".csv" with open(path, "w") as f: - with db.Session() as session: - for playdate in Playdates.played_after(session, start_dt): - f.write(f"{playdate.track.artist},{playdate.track.title}\n") + for playdate in ds.playdates_between_dates(start_dt): + f.write(f"{playdate.artist},{playdate.title}\n") def drop3db(self) -> None: """Drop music level by 3db if button checked""" - if track_sequence.current: - track_sequence.current.drop3db(self.footer_section.btnDrop3db.isChecked()) + if self.track_sequence.current: + self.track_sequence.current.drop3db( + self.footer_section.btnDrop3db.isChecked() + ) - def enable_escape(self, enabled: bool) -> None: + # @log_call + def enable_escape_signal_handler(self, enabled: bool) -> None: """ Manage signal to enable/disable handling ESC character. @@ -1839,13 +1828,13 @@ class Window(QMainWindow): so we need to disable it here while editing. """ - log.debug(f"enable_escape({enabled=})") - if "clear_selection" in self.menu_actions: self.menu_actions["clear_selection"].setEnabled(enabled) - def end_of_track_actions(self) -> None: + # @log_call + def track_ended_handler(self) -> None: """ + Called by signal_track_ended Actions required: - Reset track_sequence objects @@ -1855,16 +1844,8 @@ class Window(QMainWindow): - Enable controls """ - if track_sequence.current: - # Dereference the fade curve so it can be garbage collected - 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 - self.current.base_model.previous_track_ended() + if self.track_sequence.current: + self.track_sequence.move_current_to_previous() # Reset clocks self.footer_section.frame_fade.setStyleSheet("") @@ -1879,58 +1860,54 @@ class Window(QMainWindow): self.catch_return_key = False self.show_status_message("Play controls: Enabled", 0) - # autoplay - # if not self.stop_autoplay: - # self.play_next() - - def export_playlist_tab(self) -> None: + def export_playlist_tab(self, checked: bool = False) -> None: """Export the current playlist to an m3u file""" + if self.current is None: + return + playlist_id = self.current.playlist_id - with db.Session() as session: - # Get output filename - playlist = session.get(Playlists, playlist_id) - if not playlist: - return + playlist = ds.playlist_by_id(playlist_id) + if not playlist: + return - pathspec = QFileDialog.getSaveFileName( - self, - "Save Playlist", - directory=f"{playlist.name}.m3u", - filter="M3U files (*.m3u);;All files (*.*)", - ) - if not pathspec: - return - path = pathspec[0] - if not path.endswith(".m3u"): - path += ".m3u" + # Get output filename + pathspec = QFileDialog.getSaveFileName( + self, + "Save Playlist", + directory=f"{playlist.name}.m3u", + filter="M3U files (*.m3u);;All files (*.*)", + ) + if not pathspec: + return + path = pathspec[0] + if not path.endswith(".m3u"): + path += ".m3u" - # Get list of track rows for this playlist - plrs = PlaylistRows.get_rows_with_tracks(session, playlist_id) - with open(path, "w") as f: - # Required directive on first line - f.write("#EXTM3U\n") - for track in [a.track for a in plrs]: - if track.duration is None: - track.duration = 0 + # Get list of track rows for this playlist + with open(path, "w") as f: + # Required directive on first line + f.write("#EXTM3U\n") + for playlistrow in ds.playlistrows_by_playlist(playlist_id): + if playlistrow.track: f.write( "#EXTINF:" - f"{int(track.duration / 1000)}," - f"{track.title} - " - f"{track.artist}" + f"{int(playlistrow.track.duration / 1000)}," + f"{playlistrow.track.title} - " + f"{playlistrow.track.artist}" "\n" - f"{track.path}" + f"{playlistrow.track.path}" "\n" ) - def fade(self) -> None: + def fade(self, checked: bool = False) -> None: """Fade currently playing track""" - if track_sequence.current: - track_sequence.current.fade() + if self.track_sequence.current: + 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) -> int | None: """ Return the tab index for the passed playlist_id if it is displayed, else return None. @@ -1945,6 +1922,9 @@ class Window(QMainWindow): def hide_played(self): """Toggle hide played tracks""" + # TODO: handle this with signals, but first decide how to better + # handle hide tracks / sections + if self.hide_played_tracks: self.hide_played_tracks = False self.current.base_model.hide_played_tracks(False) @@ -1953,26 +1933,32 @@ class Window(QMainWindow): self.hide_played_tracks = True self.footer_section.btnHidePlayed.setText("Show played") if Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS: - self.active_tab().hide_played_sections() + self._active_tab().hide_played_sections() else: self.current.base_model.hide_played_tracks(True) # Reset row heights - self.active_tab().resize_rows() + self.signals.resize_rows_signal.emit(self.current.playlist_id) - def import_files_wrapper(self) -> None: + def import_files_wrapper(self, checked: bool = False) -> None: """ Pass import files call to file_importer module """ # We need to keep a reference to the FileImporter else it will be # garbage collected while import threads are still running + if self.current is None: + return + self.importer = FileImporter(self.current.base_model, self.current_row_or_end()) self.importer.start() - def insert_header(self) -> None: + def insert_header(self, checked: bool = False) -> None: """Show dialog box to enter header text and add to playlist""" + if self.current is None: + return + # Get header text dlg: QInputDialog = QInputDialog(self) dlg.setInputMode(QInputDialog.InputMode.TextInput) @@ -1980,49 +1966,39 @@ class Window(QMainWindow): dlg.resize(500, 100) ok = dlg.exec() if ok: - self.current.base_model.insert_row( - proposed_row_number=self.current_row_or_end(), - note=dlg.textValue(), + self.signals.signal_insert_track.emit( + InsertTrack( + playlist_id=self.current.playlist_id, + track_id=None, + note=dlg.textValue(), + ) ) - def insert_track(self) -> None: + def insert_track(self, checked: bool = False) -> None: """Show dialog box to select and add track from database""" - with db.Session() as session: - dlg = TrackSelectDialog( - parent=self, - session=session, - new_row_number=self.current_row_or_end(), - base_model=self.current.base_model, - ) - dlg.exec() - session.commit() + if self.current is None: + return - @log_call + dlg = TrackInsertDialog(parent=self, playlist_id=self.current.playlist_id) + dlg.exec() + + # @log_call def load_last_playlists(self) -> None: - """Load the playlists that were open when the last session closed""" + """Load the playlists that were open when app was last closed""" playlist_ids = [] - with db.Session() as session: - for playlist in Playlists.get_open(session): - if playlist: - log.debug(f"load_last_playlists() loaded {playlist=}") - # Create tab - playlist_ids.append(self._open_playlist(playlist)) + for playlist in ds.playlists_open(): + if playlist: + # Create tab + playlist_ids.append(self._open_playlist(playlist)) - # Set active tab - record = Settings.get_setting(session, "active_tab") - if record.f_int is not None and record.f_int >= 0: - self.playlist_section.tabPlaylist.setCurrentIndex(record.f_int) + # Set active tab + value = ds.setting_get("active_index") + if value is not None and value >= 0: + self.playlist_section.tabPlaylist.setCurrentIndex(value) - # Tabs may move during use. Rather than track where tabs - # are, we record the tab index when we close the main - # window. To avoid possible duplicate tab entries, we null - # them all out now. - Playlists.clear_tabs(session, playlist_ids) - session.commit() - - def lookup_row_in_songfacts(self) -> None: + def lookup_row_in_songfacts(self, checked: bool = False) -> None: """ Display songfacts page for title in highlighted row """ @@ -2031,9 +2007,9 @@ class Window(QMainWindow): if not track_info: return - self.signals.search_songfacts_signal.emit(track_info.title) + self.open_songfacts_browser(track_info.title) - def lookup_row_in_wikipedia(self) -> None: + def lookup_row_in_wikipedia(self, checked: bool = False) -> None: """ Display Wikipedia page for title in highlighted row or next track """ @@ -2042,50 +2018,52 @@ class Window(QMainWindow): if not track_info: return - self.signals.search_wikipedia_signal.emit(track_info.title) + self.open_wikipedia_browser(track_info.title) - def mark_rows_for_moving(self) -> None: + def mark_rows_for_moving(self, checked: bool = False) -> None: """ Cut rows ready for pasting. """ + if self.current is None: + return + # Save the selected PlaylistRows items ready for a later # paste - self.move_source_rows = self.current.selected_rows - self.move_source_model = self.current.base_model - - log.debug( - f"mark_rows_for_moving(): {self.move_source_rows=} {self.move_source_model=}" + self.move_source = MoveSource( + model=self.current.base_model, rows=self.current.selected_row_numbers ) + log.debug(f"mark_rows_for_moving(): {self.move_source=}") + + # @log_call def move_playlist_rows(self, row_numbers: list[int]) -> None: """ Move passed playlist rows to another playlist """ + if not row_numbers or self.current is None: + return + # Identify destination playlist playlists = [] source_playlist_id = self.current.playlist_id - with db.Session() as session: - for playlist in Playlists.get_all(session): - if playlist.id == source_playlist_id: - continue - else: - playlists.append(playlist) - - dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) - dlg.exec() - if not dlg.playlist: - return - to_playlist_id = dlg.playlist.id - - # Get row number in destination playlist - last_row = PlaylistRows.get_last_used_row(session, to_playlist_id) - if last_row is not None: - to_row = last_row + 1 + for playlist in ds.playlists_all(): + if playlist.playlist_id == source_playlist_id: + continue else: - to_row = 0 + playlists.append(playlist) + + dlg = SelectPlaylistDialog(self, playlists=playlists) + dlg.exec() + if not dlg.playlist: + return + to_playlist_id = dlg.playlist.id + + # Add to end of target playlist, so target row will be length of + # playlist + to_row = ds.playlist_row_count(to_playlist_id) # Move rows self.current.base_model.move_rows_between_playlists( @@ -2093,31 +2071,26 @@ class Window(QMainWindow): ) # Reset track_sequences - 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) + self.track_sequence.update() - def move_selected(self) -> None: + def move_selected(self, checked: bool = False) -> None: """ Move selected rows to another playlist """ - selected_rows = self.current.selected_rows - if not selected_rows: + if self.current is None: return - self.move_playlist_rows(selected_rows) + self.move_playlist_rows(self.current.selected_row_numbers) - def move_unplayed(self) -> None: + def move_unplayed(self, checked: bool = False) -> None: """ Move unplayed rows to another playlist """ + if self.current is None: + return + unplayed_rows = self.current.base_model.get_unplayed_rows() if not unplayed_rows: return @@ -2145,40 +2118,43 @@ class Window(QMainWindow): webbrowser.get("browser").open_new_tab(url) - def paste_rows(self, dummy_for_profiling: int | None = None) -> None: + # @log_call + def paste_rows(self, checked: bool = False) -> None: """ - Paste earlier cut rows. + Paste earlier rows identified in self.mark_rows_for_moving() + + 'checked' is a dummy parameter passed to us by the menu """ - if not self.move_source_rows or not self.move_source_model: + if not self.move_source or self.current is None: return to_playlist_model = self.current.base_model - destination_row = self.current_row_or_end() + from_playlist_model = self.move_source.model + to_row = self.current_row_or_end() + from_rows = self.move_source.rows + + if from_playlist_model == to_playlist_model: + from_playlist_model.move_rows(from_rows, to_row) + else: + from_playlist_model.move_rows_between_playlists( + from_rows, to_row, to_playlist_model.playlist_id + ) + + self.signals.resize_rows_signal.emit(self.current.playlist_id) + self._active_tab().clear_selection() # If we move a row to immediately under the current track, make # that moved row the next track - set_next_row: Optional[int] = None if ( - track_sequence.current - and track_sequence.current.playlist_id == to_playlist_model.playlist_id - and destination_row == track_sequence.current.row_number + 1 + self.track_sequence.current + and self.track_sequence.current.playlist_id == to_playlist_model.playlist_id + and to_row == self.track_sequence.current.row_number + 1 ): - set_next_row = destination_row + to_playlist_model.set_next_row_handler(to_row) - if to_playlist_model.playlist_id == self.move_source_model.playlist_id: - self.move_source_model.move_rows(self.move_source_rows, destination_row) - else: - self.move_source_model.move_rows_between_playlists( - self.move_source_rows, destination_row, to_playlist_model.playlist_id - ) - self.active_tab().resize_rows() - self.active_tab().clear_selection() - - if set_next_row: - to_playlist_model.set_next_row(set_next_row) - - def play_next(self, position: Optional[float] = None) -> None: + # @log_call + def play_next(self, position: float | None = None, checked: bool = False) -> None: """ Play next track, optionally from passed position. @@ -2198,7 +2174,7 @@ class Window(QMainWindow): """ # 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") return @@ -2207,7 +2183,7 @@ class Window(QMainWindow): return # Issue #223 concerns a very short pause (maybe 0.1s) sometimes - # when starting to play at track. Resolution appears to be to + # just after a track starts playing. Resolution appears to be to # disable timer10 for a short time. Timer is re-enabled in # update_clocks. @@ -2215,54 +2191,49 @@ class Window(QMainWindow): log.debug("issue223: play_next: 10ms timer disabled") # If there's currently a track playing, fade it. - if track_sequence.current: - track_sequence.current.fade() + if self.track_sequence.current: + self.track_sequence.current.fade() - # Move next track to current track. - # end_of_track_actions() will have saved current track to + # Move next track to current track. signal_track_ended_handler() will + # have been called when previous track ended or when fade() was + # called above, and that in turn will have saved current track to # previous_track - track_sequence.current = track_sequence.next - # Clear next track - self.clear_next() + self.track_sequence.move_next_to_current() + if self.track_sequence.current is None: + raise ApplicationError("No current track") # Restore volume if -3dB active if self.footer_section.btnDrop3db.isChecked(): self.footer_section.btnDrop3db.setChecked(False) # Play (new) current track - log.debug(f"Play: {track_sequence.current.title}") - track_sequence.current.play(position) + log.debug(f"Play: {self.track_sequence.current.title}") + self.track_sequence.current.play(position) # Update clocks now, don't wait for next tick self.update_clocks() # Show closing volume graph - if track_sequence.current.fade_graph: - track_sequence.current.fade_graph.GraphWidget = ( + if self.track_sequence.current.fade_graph: + self.track_sequence.current.fade_graph.GraphWidget = ( self.footer_section.widgetFadeVolume ) - track_sequence.current.fade_graph.clear() - track_sequence.current.fade_graph.plot() + self.track_sequence.current.fade_graph.clear() + self.track_sequence.current.fade_graph.plot() # Disable play next controls self.catch_return_key = True self.show_status_message("Play controls: Disabled", 0) - # Notify playlist - self.active_tab().current_track_started() + # Record playdate + ds.playdates_update(self.track_sequence.current.track_id) + + # Notify others + self.signals.signal_track_started.emit() # Update headers self.update_headers() - with db.Session() as session: - last_played = Playdates.last_played_tracks(session) - tracklist = [] - for lp in last_played: - track = session.get(Tracks, lp.track_id) - tracklist.append(f"{track.title} ({track.artist})") - tt = "
".join(tracklist) - - self.header_section.hdrPreviousTrack.setToolTip(tt) def preview(self) -> None: """ @@ -2273,35 +2244,34 @@ class Window(QMainWindow): if self.footer_section.btnPreview.isChecked(): # Get track path for first selected track if there is one - track_info = self.active_tab().get_selected_row_track_info() + track_info = self._active_tab().get_selected_row_track_info() if not track_info: # Otherwise get track_id to next track to play - if track_sequence.next: - if track_sequence.next.track_id: + if self.track_sequence.next: + if self.track_sequence.next.track_id: 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: - return if not track_info: return + self.preview_manager.row_number = track_info.row_number - with db.Session() as session: - track = session.get(Tracks, track_info.track_id) - if not track: - raise ApplicationError( - f"musicmuster.preview: unable to retreive track {track_info.track_id=}" - ) - self.preview_manager.set_track_info( - track_id=track.id, - track_path=track.path, - track_intro=track.intro - ) - self.preview_manager.play() - self.show_status_message( - f"Preview: {track.title} / {track.artist} (row {track_info.row_number})", - 0 + track = ds.track_by_id(track_info.track_id) + if not track: + raise ApplicationError( + f"musicmuster.preview: unable to retreive track {track_info.track_id=}" ) + self.preview_manager.set_track_info( + track_id=track.track_id, + track_path=track.path, + track_intro=track.intro or 0, + ) + self.preview_manager.play() + self.show_status_message( + f"Preview: {track.title} / {track.artist} (row {track_info.row_number})", + 0, + ) else: self.preview_manager.stop() self.show_status_message("", 0) @@ -2332,26 +2302,23 @@ class Window(QMainWindow): def preview_mark(self) -> None: """Set intro time""" + if self.current is None: + return + if self.preview_manager.is_playing(): track_id = self.preview_manager.track_id row_number = self.preview_manager.row_number if not row_number: return - with db.Session() as session: - track = session.get(Tracks, track_id) - if track: - # Save intro as millisends rounded to nearest 0.1 - # second because editor spinbox only resolves to 0.1 - # seconds - intro = round(self.preview_manager.get_playtime() / 100) * 100 - track.intro = intro - session.commit() - self.preview_manager.set_intro(intro) - self.current.base_model.refresh_row(session, row_number) - roles = [ - Qt.ItemDataRole.DisplayRole, - ] - self.current.base_model.invalidate_row(row_number, roles) + + intro = round(self.preview_manager.get_playtime() / 100) * 100 + ds.track_update(track_id, dict(intro=intro)) + self.preview_manager.set_intro(intro) + self.current.base_model.refresh_row(row_number) + roles = [ + Qt.ItemDataRole.DisplayRole, + ] + self.current.base_model.invalidate_row(row_number, roles) def preview_start(self) -> None: """Restart preview""" @@ -2374,21 +2341,21 @@ class Window(QMainWindow): if ok: log.debug("quicklog: " + dlg.textValue()) - def rename_playlist(self) -> None: + def rename_playlist(self, checked: bool = False) -> None: """ - Rename current playlist + Rename current playlist. checked is passed by menu but not used here """ - with db.Session() as session: - playlist_id = self.current.playlist_id - playlist = session.get(Playlists, playlist_id) - if playlist: - new_name = self.get_playlist_name(session, playlist.name) - if new_name: - playlist.rename(session, new_name) - idx = self.tabBar.currentIndex() - self.tabBar.setTabText(idx, new_name) - session.commit() + if self.current is None: + return + + playlist = ds.playlist_by_id(self.current.playlist_id) + if playlist: + new_name = self.get_playlist_name(playlist.name) + if new_name: + ds.playlist_rename(playlist.playlist_id, new_name) + idx = self.tabBar.currentIndex() + self.tabBar.setTabText(idx, new_name) def return_pressed_in_error(self) -> bool: """ @@ -2397,12 +2364,12 @@ class Window(QMainWindow): 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 if ( - track_sequence.current - and track_sequence.current.start_time - and track_sequence.current.start_time + self.track_sequence.current + and self.track_sequence.current.start_time + and self.track_sequence.current.start_time + dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS) > dt.datetime.now() ): @@ -2411,8 +2378,10 @@ class Window(QMainWindow): # If return is pressed during first PLAY_NEXT_GUARD_MS then # default to NOT playing the next track, else default to # playing it. - default_yes: bool = track_sequence.current.start_time is not None and ( - (dt.datetime.now() - track_sequence.current.start_time).total_seconds() + default_yes: bool = self.track_sequence.current.start_time is not None and ( + ( + dt.datetime.now() - self.track_sequence.current.start_time + ).total_seconds() * 1000 > Config.PLAY_NEXT_GUARD_MS ) @@ -2430,7 +2399,7 @@ class Window(QMainWindow): return False - def resume(self) -> None: + def resume(self, checked: bool = False) -> None: """ Resume playing last track. We may be playing the next track or none; take care of both eventualities. @@ -2441,18 +2410,18 @@ class Window(QMainWindow): - If a track is playing, make that the next track """ - if not track_sequence.previous: + if not self.track_sequence.previous: return # Return if no saved position - resume_marker = track_sequence.previous.resume_marker + resume_marker = self.track_sequence.previous.resume_marker if not resume_marker: log.error("No previous track position") return # We want to use play_next() to resume, so copy the previous # 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 self.play_next(resume_marker) @@ -2461,23 +2430,26 @@ class Window(QMainWindow): # We need to fake the start time to reflect where we resumed the # track if ( - track_sequence.current - and track_sequence.current.start_time - and track_sequence.current.duration - and track_sequence.current.resume_marker + self.track_sequence.current + and self.track_sequence.current.start_time + and self.track_sequence.current.duration + and self.track_sequence.current.resume_marker ): elapsed_ms = ( - track_sequence.current.duration * track_sequence.current.resume_marker + self.track_sequence.current.duration + * self.track_sequence.current.resume_marker + ) + self.track_sequence.current.start_time -= dt.timedelta( + milliseconds=elapsed_ms ) - track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms) - def search_playlist(self) -> None: + def search_playlist(self, checked: bool = False) -> None: """Show text box to search playlist""" # Disable play controls so that 'return' in search box doesn't # play next track self.catch_return_key = True - self.txtSearch.setHidden(False) + self.txtSearch.setVisible(True) self.txtSearch.setFocus() # Select any text that may already be there self.txtSearch.selectAll() @@ -2494,22 +2466,28 @@ class Window(QMainWindow): Incremental search of playlist """ + if self.current is None: + return + self.current.proxy_model.set_incremental_search(self.txtSearch.text()) - def selected_or_next_track_info(self) -> Optional[RowAndTrack]: + def selected_or_next_track_info(self) -> PlaylistRow | None: """ Return RowAndTrack info for selected track. If no selected track, return for next track. If no next track, return None. """ - row_number: Optional[int] = None + if self.current is None: + return None - if self.current.selected_rows: - row_number = self.current.selected_rows[0] + row_number: int | None = None + + if self.current.selected_row_numbers: + row_number = self.current.selected_row_numbers[0] if row_number is None: - if track_sequence.next: - if track_sequence.next.track_id: - row_number = track_sequence.next.row_number + if self.track_sequence.next: + if self.track_sequence.next.track_id: + row_number = self.track_sequence.next.row_number if row_number is None: return None @@ -2522,54 +2500,46 @@ class Window(QMainWindow): def set_main_window_size(self) -> None: """Set size of window from database""" - with db.Session() as session: - x = Settings.get_setting(session, "mainwindow_x").f_int or 100 - y = Settings.get_setting(session, "mainwindow_y").f_int or 100 - width = Settings.get_setting(session, "mainwindow_width").f_int or 100 - height = Settings.get_setting(session, "mainwindow_height").f_int or 100 - self.setGeometry(x, y, width, height) + x = ds.setting_get("mainwindow_x") or 100 + y = ds.setting_get("mainwindow_y") or 100 + width = ds.setting_get("mainwindow_width") or 100 + height = ds.setting_get("mainwindow_height") or 100 + self.setGeometry(x, y, width, height) - def set_selected_track_next(self) -> None: + # @log_call + def set_selected_track_next(self, checked: bool = False) -> None: """ Set currently-selected row on visible playlist tab as next track """ - playlist_tab = self.active_tab() - if playlist_tab: - playlist_tab.set_row_as_next_track() - else: - log.error("No active tab") + if self.current is None: + return - def set_tab_colour(self, widget: PlaylistTab, colour: QColor) -> None: - """ - Find the tab containing the widget and set the text colour - """ - - idx = self.playlist_section.tabPlaylist.indexOf(widget) - self.playlist_section.tabPlaylist.tabBar().setTabTextColor(idx, colour) + self.signals.signal_set_next_row.emit(self.current.playlist_id) + self.clear_selection() def show_current(self) -> None: """Scroll to show current track""" - if track_sequence.current: - self.show_track(track_sequence.current) + if self.track_sequence.current: + self.show_track(self.track_sequence.current) def show_warning(self, title: str, body: str) -> None: """ - Display a warning dialog + Handle show_warning_signal and display a warning dialog """ - print(f"show_warning({title=}, {body=})") QMessageBox.warning(self, title, body) def show_next(self) -> None: """Scroll to show next track""" - if track_sequence.next: - self.show_track(track_sequence.next) + if self.track_sequence.next: + self.show_track(self.track_sequence.next) def show_status_message(self, message: str, timing: int) -> None: """ + Handle status_message_signal. Show status message in status bar for timing milliseconds Clear message if message is null string """ @@ -2580,8 +2550,11 @@ class Window(QMainWindow): else: self.statusbar.clearMessage() - def show_track(self, playlist_track: RowAndTrack) -> None: - """Scroll to show track in plt""" + def show_track(self, playlist_track: PlaylistRow) -> None: + """Scroll to show track""" + + if self.current is None: + return # Switch to the correct tab playlist_id = playlist_track.playlist_id @@ -2599,41 +2572,67 @@ class Window(QMainWindow): f"show_track() can't find current playlist tab {playlist_id=}" ) - self.active_tab().scroll_to_top(playlist_track.row_number) + self._active_tab().scroll_to_top(playlist_track.row_number) - def stop(self) -> None: + def playlist_selected_rows_handler(self, selected_rows: SelectedRows) -> None: + """ + Handle signal_playlist_selected_rows to keep track of which rows + are selected in the current model + """ + + if self.current is None: + return + + self.current.selected_row_numbers = selected_rows.rows + + def set_next_track_handler(self, plr: PlaylistRow) -> None: + """ + Handle signal_set_next_track + """ + + self.track_sequence.set_next(plr) + self.signals.signal_next_track_changed.emit() + + def next_track_changed_handler(self) -> None: + """ + Handle next track changed + """ + + self.update_headers() + + # @log_call + def stop(self, checked: bool = False) -> None: """Stop playing immediately""" - self.stop_autoplay = True - if track_sequence.current: - track_sequence.current.stop() + if self.track_sequence.current: + self.track_sequence.current.stop() def tab_change(self) -> None: """Called when active tab changed""" - self.active_tab().tab_live() + self._active_tab().tab_live() def tick_10ms(self) -> None: """ Called every 10ms """ - if track_sequence.current: - track_sequence.current.update_fade_graph() + if self.track_sequence.current: + self.track_sequence.current.update_fade_graph() def tick_100ms(self) -> None: """ Called every 100ms """ - if track_sequence.current: + if self.track_sequence.current: try: - track_sequence.current.check_for_end_of_track() - - # Update intro counter if applicable and, if updated, return - # because playing an intro takes precedence over timing a + # Update intro counter if applicable and, if updated, + # return because playing an intro uses the intro field to + # show timing and this takes precedence over timing a # 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: self.footer_section.label_intro_timer.setText( f"{intro_ms_remaining / 1000:.1f}" @@ -2651,7 +2650,6 @@ class Window(QMainWindow): # current track ended during servicing tick pass - # Ensure preview button is reset if preview finishes playing # Update preview timer if self.footer_section.btnPreview.isChecked(): if self.preview_manager.is_playing(): @@ -2663,6 +2661,8 @@ class Window(QMainWindow): f"{int(minutes)}:{seconds:04.1f}" ) else: + # Ensure preview button is reset if preview has finished + # playing self.footer_section.btnPreview.setChecked(False) self.footer_section.label_intro_timer.setText("0.0") self.footer_section.label_intro_timer.setStyleSheet("") @@ -2693,17 +2693,20 @@ class Window(QMainWindow): """ # 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.music.is_playing() + ): # Elapsed time 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 = track_sequence.current.time_to_fade() - time_to_silence = track_sequence.current.time_to_silence() + time_to_fade = self.track_sequence.current.time_to_fade() + time_to_silence = self.track_sequence.current.time_to_silence() self.footer_section.label_fade_timer.setText( helpers.ms_to_mmss(time_to_fade) ) @@ -2748,30 +2751,48 @@ class Window(QMainWindow): helpers.ms_to_mmss(time_to_silence) ) + def update_current( + self, + base_model: PlaylistModel, + proxy_model: PlaylistProxyModel, + playlist_id: int, + selected_row_numbers: list[int], + ) -> None: + """ + Update self.current when playlist tab changes. Called by new playlist + """ + + self.current = Current( + base_model=base_model, + proxy_model=proxy_model, + playlist_id=playlist_id, + selected_row_numbers=selected_row_numbers, + ) + def update_headers(self) -> None: """ Update last / current / next track headers """ - if track_sequence.previous: + if self.track_sequence.previous: 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: self.header_section.hdrPreviousTrack.setText("") - if track_sequence.current: + if self.track_sequence.current: self.header_section.hdrCurrentTrack.setText( - f"{track_sequence.current.title.replace('&', '&&')} - " - f"{track_sequence.current.artist.replace('&', '&&')}" + f"{self.track_sequence.current.title.replace('&', '&&')} - " + f"{self.track_sequence.current.artist.replace('&', '&&')}" ) else: self.header_section.hdrCurrentTrack.setText("") - if track_sequence.next: + if self.track_sequence.next: self.header_section.hdrNextTrack.setText( - f"{track_sequence.next.title.replace('&', '&&')} - " - f"{track_sequence.next.artist.replace('&', '&&')}" + f"{self.track_sequence.next.title.replace('&', '&&')} - " + f"{self.track_sequence.next.artist.replace('&', '&&')}" ) else: self.header_section.hdrNextTrack.setText("") @@ -2786,25 +2807,26 @@ class Window(QMainWindow): # Do we need to set a 'next' icon? set_next = True if ( - track_sequence.current - and track_sequence.next - and track_sequence.current.playlist_id == track_sequence.next.playlist_id + self.track_sequence.current + and self.track_sequence.next + and self.track_sequence.current.playlist_id + == self.track_sequence.next.playlist_id ): set_next = False for idx in range(self.tabBar.count()): widget = self.playlist_section.tabPlaylist.widget(idx) if ( - track_sequence.next + self.track_sequence.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( idx, QIcon(Config.PLAYLIST_ICON_NEXT) ) elif ( - track_sequence.current - and widget.playlist_id == track_sequence.current.playlist_id + self.track_sequence.current + and widget.playlist_id == self.track_sequence.current.playlist_id ): self.playlist_section.tabPlaylist.setTabIcon( idx, QIcon(Config.PLAYLIST_ICON_CURRENT) @@ -2852,12 +2874,10 @@ if __name__ == "__main__": # Run as required if args.check_db: log.debug("Checking database") - with db.Session() as session: - check_db(session) + check_db() elif args.update_bitrates: log.debug("Update bitrates") - with db.Session() as session: - update_bitrates(session) + update_bitrates() else: app = QApplication(sys.argv) try: diff --git a/app/playlistmodel.py b/app/playlistmodel.py index e930884..5343219 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -3,7 +3,7 @@ from __future__ import annotations from operator import attrgetter from random import shuffle -from typing import cast, Optional +from typing import cast import datetime as dt import re @@ -25,7 +25,6 @@ from PyQt6.QtGui import ( # Third party imports # import line_profiler -from sqlalchemy.orm.session import Session import obswebsocket # type: ignore # import snoop # type: ignore @@ -34,21 +33,24 @@ import obswebsocket # type: ignore from classes import ( ApplicationError, Col, + InsertRows, + InsertTrack, MusicMusterSignals, + SelectedRows, + TrackAndPlaylist, ) from config import Config from helpers import ( ask_yes_no, file_is_unreadable, + get_all_track_metadata, get_embedded_time, get_relative_date, ms_to_mmss, - remove_substring_case_insensitive, - set_track_metadata, ) from log import log, log_call -from models import db, NoteColours, Playdates, PlaylistRows, Tracks -from music_manager import RowAndTrack, track_sequence +from playlistrow import PlaylistRow, TrackSequence +import ds HEADER_NOTES_COLUMN = 1 @@ -59,6 +61,9 @@ class PlaylistModel(QAbstractTableModel): """ The Playlist Model + Cache the database info in self.playlist_rows, a dictionary of + PlaylistRow objects indexed by row_number. + Update strategy: update the database and then refresh the row-indexed cached copy (self.playlist_rows). Do not edit self.playlist_rows directly because keeping it and the @@ -78,23 +83,33 @@ class PlaylistModel(QAbstractTableModel): ) -> None: super().__init__() - log.debug("PlaylistModel.__init__()") - self.playlist_id = playlist_id self.is_template = is_template + self.track_sequence = TrackSequence() - self.playlist_rows: dict[int, RowAndTrack] = {} + self.playlist_rows: dict[int, PlaylistRow] = {} + self.selected_rows: list[PlaylistRow] = [] self.signals = MusicMusterSignals() self.played_tracks_hidden = False - self.signals.begin_reset_model_signal.connect(self.begin_reset_model) - self.signals.end_reset_model_signal.connect(self.end_reset_model) + # Connect signals + self.signals.signal_add_track_to_header.connect( + self.signal_add_track_to_header_handler + ) + self.signals.signal_begin_insert_rows.connect(self.begin_insert_rows_handler) + self.signals.signal_end_insert_rows.connect(self.end_insert_rows_handler) + self.signals.signal_insert_track.connect(self.insert_row_signal_handler) + self.signals.signal_playlist_selected_rows.connect( + self.playlist_selected_rows_handler + ) + self.signals.signal_set_next_row.connect(self.set_next_row_handler) + self.signals.signal_track_started.connect(self.track_started_handler) + self.signals.signal_track_ended.connect(self.signal_track_ended_handler) + self.signals.signal_next_track_changed.connect(self.next_track_changed_handler) - with db.Session() as session: - # Ensure row numbers in playlist are contiguous - PlaylistRows.fixup_rownumbers(session, playlist_id) - # Populate self.playlist_rows - self.load_data(session) + # Populate self.playlist_rows + for dto in ds.playlistrows_by_playlist(self.playlist_id): + self.playlist_rows[dto.row_number] = PlaylistRow(dto) self.update_track_times() def __repr__(self) -> str: @@ -126,7 +141,7 @@ class PlaylistModel(QAbstractTableModel): # playing it. It's also possible that the track marked as # 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 ( ts and ts.row_number == row_number @@ -138,124 +153,111 @@ class PlaylistModel(QAbstractTableModel): return header_row - def add_track_to_header( - self, row_number: int, track_id: int, note: Optional[str] = None + # @log_call + def signal_add_track_to_header_handler( + self, track_and_playlist: TrackAndPlaylist ) -> None: """ - Add track to existing header row + Handle signal_add_track_to_header """ - log.debug(f"{self}: add_track_to_header({row_number=}, {track_id=}, {note=}") + if track_and_playlist.playlist_id != self.playlist_id: + return - # Get existing row - try: - rat = self.playlist_rows[row_number] - except KeyError: - raise ApplicationError( - f"{self}: KeyError in add_track_to_header ({row_number=}, {track_id=})" + if not self.selected_rows: + self.signals.show_warning_signal.emit( + "Add track to header", "Add track to header but no row selected" ) - if rat.path: - raise ApplicationError( - f"{self}: Header row already has track associated ({rat=}, {track_id=})" - ) - with db.Session() as session: - playlistrow = session.get(PlaylistRows, rat.playlistrow_id) - if not playlistrow: - raise ApplicationError( - f"{self}: Failed to retrieve playlist row ({rat.playlistrow_id=}" - ) - # Add track to PlaylistRows - playlistrow.track_id = track_id - # Add any further note (header will already have a note) - if note: - playlistrow.note += " " + note - session.commit() - # Update local copy - self.refresh_row(session, row_number) - # Repaint row - roles = [ - Qt.ItemDataRole.BackgroundRole, - Qt.ItemDataRole.DisplayRole, - Qt.ItemDataRole.FontRole, - Qt.ItemDataRole.ForegroundRole, - ] - # only invalidate required roles - self.invalidate_row(row_number, roles) + if len(self.selected_rows) > 1: + self.signals.show_warning_signal.emit( + "Add track to header", "Select one header to add track to" + ) + return + + selected_row = self.selected_rows[0] + if selected_row.path: + self.signals.show_warning_signal.emit( + "Add track to header", "Select a header to add track to" + ) + return + + selected_row.track_id = track_and_playlist.track_id + + # Update local copy + self.refresh_row(selected_row.row_number) + # Repaint row + roles_to_invalidate = [ + Qt.ItemDataRole.BackgroundRole, + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.FontRole, + Qt.ItemDataRole.ForegroundRole, + ] + # only invalidate required roles + self.invalidate_row(selected_row.row_number, roles_to_invalidate) self.signals.resize_rows_signal.emit(self.playlist_id) - def _background_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush: + def _background_role(self, row: int, column: int, plr: PlaylistRow) -> QBrush: """Return background setting""" # Handle entire row colouring # Header row if self.is_header_row(row): # Check for specific header colouring - if rat.row_bg is None: - with db.Session() as session: - rat.row_bg = NoteColours.get_colour(session, rat.note) - if rat.row_bg: - return QBrush(QColor(rat.row_bg)) + if plr.row_bg is None: + plr.row_bg = ds.notecolours_get_colour(plr.note) + if plr.row_bg: + return QBrush(QColor(plr.row_bg)) else: return QBrush(QColor(Config.COLOUR_NOTES_PLAYLIST)) # Unreadable track file - if file_is_unreadable(rat.path): + if file_is_unreadable(plr.path): return QBrush(QColor(Config.COLOUR_UNREADABLE)) # Current track if ( - track_sequence.current - and track_sequence.current.playlist_id == self.playlist_id - and track_sequence.current.row_number == row + self.track_sequence.current + and self.track_sequence.current.playlist_id == self.playlist_id + and self.track_sequence.current.row_number == row ): return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST)) # Next track if ( - track_sequence.next - and track_sequence.next.playlist_id == self.playlist_id - and track_sequence.next.row_number == row + self.track_sequence.next + and self.track_sequence.next.playlist_id == self.playlist_id + and self.track_sequence.next.row_number == row ): return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST)) # Individual cell colouring if column == Col.START_GAP.value: - if rat.start_gap and rat.start_gap >= Config.START_GAP_WARNING_THRESHOLD: + if plr.start_gap and plr.start_gap >= Config.START_GAP_WARNING_THRESHOLD: return QBrush(QColor(Config.COLOUR_LONG_START)) if column == Col.BITRATE.value: - if not rat.bitrate or rat.bitrate < Config.BITRATE_LOW_THRESHOLD: + if not plr.bitrate or plr.bitrate < Config.BITRATE_LOW_THRESHOLD: return QBrush(QColor(Config.COLOUR_BITRATE_LOW)) - elif rat.bitrate < Config.BITRATE_OK_THRESHOLD: + elif plr.bitrate < Config.BITRATE_OK_THRESHOLD: return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM)) else: return QBrush(QColor(Config.COLOUR_BITRATE_OK)) if column == Col.NOTE.value: - if rat.note: - if rat.note_bg is None: - with db.Session() as session: - rat.note_bg = NoteColours.get_colour(session, rat.note) - if rat.note_bg: - return QBrush(QColor(rat.note_bg)) + if plr.note: + if plr.note_bg is None: + plr.row_bg = ds.notecolours_get_colour(plr.note) + if plr.note_bg: + return QBrush(QColor(plr.note_bg)) return QBrush() - def begin_reset_model(self, playlist_id: int) -> None: - """ - Reset model if playlist_id is ours - """ - - if playlist_id != self.playlist_id: - return - super().beginResetModel() - def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: """Standard function for view""" return len(Col) - def current_track_started(self) -> None: + # @log_call + def track_started_handler(self) -> None: """ - Notification from musicmuster that the current track has just - started playing + Handle signal_track_started signal. Actions required: - sanity check @@ -267,70 +269,64 @@ class PlaylistModel(QAbstractTableModel): - update track times """ - log.debug(f"{self}: current_track_started()") + if self.track_sequence.current is None: + raise ApplicationError("track_started called with no current track") - if not track_sequence.current: + if self.track_sequence.current.playlist_id != self.playlist_id: + # Not for us return - row_number = track_sequence.current.row_number + track_id = self.track_sequence.current.track_id + if not track_id: + raise ApplicationError("track_started() called with no track_id") + row_number = self.track_sequence.current.row_number # Check for OBS scene change self.obs_scene_change(row_number) - # Sanity check that we have a track_id - track_id = track_sequence.current.track_id - if not track_id: - raise ApplicationError( - f"{self}: current_track_started() called with {track_id=}" - ) + # Update Playdates in database + ds.playdates_update(track_id) - with db.Session() as session: - # Update Playdates in database - log.debug(f"{self}: update playdates {track_id=}") - Playdates(session, track_id) - session.commit() + # Mark track as played in playlist + self.playlist_rows[row_number].played = True - # Mark track as played in playlist - log.debug(f"{self}: Mark track as played") - 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 + roles = [Qt.ItemDataRole.DisplayRole] + self.invalidate_row(row_number, roles) - # Update colour and times for current row + # Update previous row in case we're hiding played rows + if self.track_sequence.previous and self.track_sequence.previous.row_number: # only invalidate required roles - roles = [ - Qt.ItemDataRole.DisplayRole - ] - self.invalidate_row(row_number, roles) + self.invalidate_row(self.track_sequence.previous.row_number, roles) - # Update previous row in case we're hiding played rows - if track_sequence.previous and track_sequence.previous.row_number: - # only invalidate required roles - self.invalidate_row(track_sequence.previous.row_number, roles) + # Update all other track times + self.update_track_times() - # Update all other track times - self.update_track_times() + # 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]) - # Find next track - next_row = None - unplayed_rows = [ - a - for a in self.get_unplayed_rows() - if not self.is_header_row(a) - and not file_is_unreadable(self.playlist_rows[a].path) - ] - if unplayed_rows: - try: - next_row = min([a for a in unplayed_rows if a > row_number]) - except ValueError: - next_row = min(unplayed_rows) - if next_row is not None: - self.set_next_row(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 + unplayed_rows = [ + a + for a in self.get_unplayed_rows() + if not self.is_header_row(a) + and not file_is_unreadable(self.playlist_rows[a].path) + ] + if unplayed_rows: + try: + next_row = min([a for a in unplayed_rows if a > from_row_number]) + except ValueError: + next_row = min(unplayed_rows) + + return next_row def data( self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole @@ -355,26 +351,27 @@ class PlaylistModel(QAbstractTableModel): row = index.row() column = index.column() - # rat for playlist row data as it's used a lot - rat = self.playlist_rows[row] + # plr for playlist row data as it's used a lot + plr = self.playlist_rows[row] # These are ordered in approximately the frequency with which # they are called if role == Qt.ItemDataRole.BackgroundRole: - return self._background_role(row, column, rat) + return self._background_role(row, column, plr) elif role == Qt.ItemDataRole.DisplayRole: - return self._display_role(row, column, rat) + return self._display_role(row, column, plr) elif role == Qt.ItemDataRole.EditRole: - return self._edit_role(row, column, rat) + return self._edit_role(row, column, plr) elif role == Qt.ItemDataRole.FontRole: - return self._font_role(row, column, rat) + return self._font_role(row, column, plr) elif role == Qt.ItemDataRole.ForegroundRole: - return self._foreground_role(row, column, rat) + return self._foreground_role(row, column, plr) elif role == Qt.ItemDataRole.ToolTipRole: - return self._tooltip_role(row, column, rat) + return self._tooltip_role(row, column, plr) return QVariant() + # @log_call def delete_rows(self, row_numbers: list[int]) -> None: """ Delete passed rows from model @@ -382,29 +379,22 @@ class PlaylistModel(QAbstractTableModel): Need to delete them in contiguous groups wrapped in beginRemoveRows / endRemoveRows calls. To keep it simple, if inefficient, delete rows one by one. - TODO: delete in blocks - Delete from highest row back so that not yet deleted row numbers don't change. """ - with db.Session() as session: - for row_number in sorted(row_numbers, reverse=True): - log.debug(f"{self}: delete_rows(), {row_number=}") - super().beginRemoveRows(QModelIndex(), row_number, row_number) - # We need to remove data from the underlying data store, - # which is the database, but we cache in - # self.playlist_rows, which is what calls to data() - # reads, so fixup that too. - PlaylistRows.delete_row(session, self.playlist_id, row_number) - PlaylistRows.fixup_rownumbers(session, self.playlist_id) - self.refresh_data(session) - session.commit() - super().endRemoveRows() + for row_group in self._reversed_contiguous_row_groups(row_numbers): + # Signal that rows will be removed + super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group)) + # Remove rows from data store + ds.playlist_remove_rows(self.playlist_id, row_group) + # Signal that data store has been updated + super().endRemoveRows() - self.reset_track_sequence_row_numbers() + self.refresh_data() + self.track_sequence.update() self.update_track_times() - def _display_role(self, row: int, column: int, rat: RowAndTrack) -> str: + def _display_role(self, row: int, column: int, plr: PlaylistRow) -> str: """ Return text for display """ @@ -422,64 +412,49 @@ class PlaylistModel(QAbstractTableModel): if header_row: if column == HEADER_NOTES_COLUMN: - header_text = self.header_text(rat) + header_text = self.header_text(plr) if not header_text: return Config.SECTION_HEADER else: - formatted_header = self.header_text(rat) + formatted_header = self.header_text(plr) trimmed_header = self.remove_section_timer_markers(formatted_header) return trimmed_header else: return "" if column == Col.START_TIME.value: - start_time = rat.forecast_start_time + start_time = plr.forecast_start_time if start_time: return start_time.strftime(Config.TRACK_TIME_FORMAT) return "" if column == Col.END_TIME.value: - end_time = rat.forecast_end_time + end_time = plr.forecast_end_time if end_time: return end_time.strftime(Config.TRACK_TIME_FORMAT) return "" if column == Col.INTRO.value: - if rat.intro: - return f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}" + if plr.intro: + return f"{plr.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}" else: return "" dispatch_table: dict[int, str] = { - Col.ARTIST.value: rat.artist, - Col.BITRATE.value: str(rat.bitrate), - Col.DURATION.value: ms_to_mmss(rat.duration), - Col.LAST_PLAYED.value: get_relative_date(rat.lastplayed), - Col.NOTE.value: rat.note, - Col.START_GAP.value: str(rat.start_gap), - Col.TITLE.value: rat.title, + Col.ARTIST.value: plr.artist, + Col.BITRATE.value: str(plr.bitrate), + Col.DURATION.value: ms_to_mmss(plr.duration), + Col.LAST_PLAYED.value: get_relative_date(plr.lastplayed), + Col.NOTE.value: plr.note, + Col.START_GAP.value: str(plr.start_gap), + Col.TITLE.value: plr.title, } if column in dispatch_table: return dispatch_table[column] return "" - def end_reset_model(self, playlist_id: int) -> None: - """ - End model reset if this is our playlist - """ - - log.debug(f"{self}: end_reset_model({playlist_id=})") - - if playlist_id != self.playlist_id: - log.debug(f"{self}: end_reset_model: not us ({self.playlist_id=})") - return - with db.Session() as session: - self.refresh_data(session) - super().endResetModel() - self.reset_track_sequence_row_numbers() - - def _edit_role(self, row: int, column: int, rat: RowAndTrack) -> str | int: + def _edit_role(self, row: int, column: int, plr: PlaylistRow) -> str | int: """ Return value for editing """ @@ -487,31 +462,25 @@ class PlaylistModel(QAbstractTableModel): # If this is a header row and we're being asked for the # HEADER_NOTES_COLUMN, return the note value if self.is_header_row(row) and column == HEADER_NOTES_COLUMN: - return rat.note + return plr.note if column == Col.INTRO.value: - return rat.intro or 0 + return plr.intro or 0 if column == Col.TITLE.value: - return rat.title + return plr.title if column == Col.ARTIST.value: - return rat.artist + return plr.artist if column == Col.NOTE.value: - return rat.note + return plr.note return "" - def _foreground_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush: + def _foreground_role(self, row: int, column: int, plr: PlaylistRow) -> QBrush: """Return header foreground colour or QBrush() if none""" - if self.is_header_row(row): - if rat.row_fg is None: - with db.Session() as session: - rat.row_fg = NoteColours.get_colour( - session, rat.note, foreground=True - ) - if rat.row_fg: - return QBrush(QColor(rat.row_fg)) - + plr.row_fg = ds.notecolours_get_colour(plr.note, foreground=True) + if plr.row_fg: + return QBrush(QColor(plr.row_fg)) return QBrush() def flags(self, index: QModelIndex) -> Qt.ItemFlag: @@ -537,7 +506,7 @@ class PlaylistModel(QAbstractTableModel): return default - def _font_role(self, row: int, column: int, rat: RowAndTrack) -> QFont: + def _font_role(self, row: int, column: int, plr: PlaylistRow) -> QFont: """ Return font """ @@ -551,14 +520,13 @@ class PlaylistModel(QAbstractTableModel): return boldfont + # @log_call def get_duplicate_rows(self) -> list[int]: """ Return a list of duplicate rows. If track appears in rows 2, 3 and 4, return [3, 4] (ie, ignore the first, not-yet-duplicate, track). """ - log.debug(f"{self}: get_duplicate_rows() called") - found = [] result = [] @@ -571,39 +539,31 @@ class PlaylistModel(QAbstractTableModel): else: found.append(track_id) - log.debug(f"{self}: get_duplicate_rows() returned: {result=}") return result - def _get_new_row_number(self, proposed_row_number: Optional[int]) -> int: + # @log_call + def _get_new_row_number(self) -> int: """ - Sanitises proposed new row number. + Get row number for new row. - If proposed_row_number given, ensure it is valid. - If not given, return row number to add to end of model. + If any rows are selected, return the first such row number, else + return row number to add to end of model. """ - log.debug(f"{self}: _get_new_row_number({proposed_row_number=})") + if not self.selected_rows: + return len(self.playlist_rows) - if proposed_row_number is None or proposed_row_number > len(self.playlist_rows): - # We are adding to the end of the list - new_row_number = len(self.playlist_rows) - elif proposed_row_number < 0: - # Add to start of list - new_row_number = 0 - else: - new_row_number = proposed_row_number + return self.selected_rows[0].row_number - log.debug(f"{self}: get_new_row_number() return: {new_row_number=}") - return new_row_number - - def get_row_info(self, row_number: int) -> RowAndTrack: + def get_row_info(self, row_number: int) -> PlaylistRow: """ Return info about passed row """ return self.playlist_rows[row_number] - def get_row_track_id(self, row_number: int) -> Optional[int]: + # @log_call + def get_row_track_id(self, row_number: int) -> int | None: """ Return id of track associated with row or None if no track associated """ @@ -679,21 +639,21 @@ class PlaylistModel(QAbstractTableModel): return QVariant() - def header_text(self, rat: RowAndTrack) -> str: + def header_text(self, plr: PlaylistRow) -> str: """ Process possible section timing directives embeded in header """ - if rat.note.endswith(Config.SECTION_STARTS): - return self.start_of_timed_section_header(rat) + if plr.note.endswith(Config.SECTION_STARTS): + return self.start_of_timed_section_header(plr) - elif rat.note.endswith("="): - return self.section_subtotal_header(rat) + elif plr.note.endswith("="): + return self.section_subtotal_header(plr) - elif rat.note == "-": + elif plr.note == "-": # If the hyphen is the only thing on the line, echo the note # that started the section without the trailing "+". - for row_number in range(rat.row_number - 1, -1, -1): + for row_number in range(plr.row_number - 1, -1, -1): row_rat = self.playlist_rows[row_number] if self.is_header_row(row_number): if row_rat.note.endswith("-"): @@ -703,7 +663,7 @@ class PlaylistModel(QAbstractTableModel): return f"[End: {row_rat.note[:-1]}]" return "-" - return rat.note + return plr.note def hide_played_tracks(self, hide: bool) -> None: """ @@ -719,65 +679,77 @@ class PlaylistModel(QAbstractTableModel): ] self.invalidate_row(row_number, roles) - def insert_row( - self, - proposed_row_number: Optional[int], - track_id: Optional[int] = None, - note: str = "", - ) -> None: + # @log_call + def insert_row_signal_handler(self, row_data: InsertTrack) -> None: """ - Insert a row. + Handle the signal_insert_track signal """ - log.debug(f"{self}: insert_row({proposed_row_number=}, {track_id=}, {note=})") + if row_data.playlist_id != self.playlist_id: + return - new_row_number = self._get_new_row_number(proposed_row_number) + new_row_number = self._get_new_row_number() - with db.Session() as session: + # Check whether track is already in playlist + move_existing = False + if row_data.track_id: + existing_plr = self.is_track_in_playlist(row_data.track_id) + if existing_plr is not None: + if ask_yes_no( + "Duplicate row", + "Track already in playlist. " "Move to new location?", + default_yes=True, + ): + move_existing = True + + if move_existing and existing_plr: + self.move_track_add_note(new_row_number, existing_plr, note="") + else: super().beginInsertRows(QModelIndex(), new_row_number, new_row_number) - _ = PlaylistRows.insert_row( - session=session, - playlist_id=self.playlist_id, - new_row_number=new_row_number, - note=note, - track_id=track_id, - ) - session.commit() - self.refresh_data(session) + _ = ds.playlist_insert_row( + playlist_id=self.playlist_id, + row_number=new_row_number, + track_id=row_data.track_id, + note=row_data.note, + ) super().endInsertRows() + # Need to refresh self.playlist_rows because row numbers will have + # changed + self.refresh_data() self.signals.resize_rows_signal.emit(self.playlist_id) - self.reset_track_sequence_row_numbers() - # only invalidate required roles - roles = [ + self.track_sequence.update() + self.update_track_times() + roles_to_invalidate = [ Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.FontRole, Qt.ItemDataRole.ForegroundRole, ] - self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))), roles) + self.invalidate_rows( + list(range(new_row_number, len(self.playlist_rows))), roles_to_invalidate + ) + # @log_call def invalidate_row(self, modified_row: int, roles: list[Qt.ItemDataRole]) -> None: """ Signal to view to refresh invalidated row """ - log.debug(f"issue285: {self}: invalidate_row({modified_row=})") - self.dataChanged.emit( self.index(modified_row, 0), self.index(modified_row, self.columnCount() - 1), - roles + roles, ) - def invalidate_rows(self, modified_rows: list[int], roles: list[Qt.ItemDataRole]) -> None: + def invalidate_rows( + self, modified_rows: list[int], roles: list[Qt.ItemDataRole] + ) -> None: """ Signal to view to refresh invlidated rows """ - log.debug(f"issue285: {self}: invalidate_rows({modified_rows=})") - for modified_row in modified_rows: # only invalidate required roles self.invalidate_row(modified_row, roles) @@ -791,6 +763,7 @@ class PlaylistModel(QAbstractTableModel): return self.playlist_rows[row_number].path == "" return False + # @log_call def is_played_row(self, row_number: int) -> bool: """ Return True if row is an unplayed track row, else False @@ -798,7 +771,7 @@ class PlaylistModel(QAbstractTableModel): return self.playlist_rows[row_number].played - def is_track_in_playlist(self, track_id: int) -> Optional[RowAndTrack]: + def is_track_in_playlist(self, track_id: int) -> PlaylistRow | None: """ If this track_id is in the playlist, return the RowAndTrack object else return None @@ -810,49 +783,14 @@ class PlaylistModel(QAbstractTableModel): return None - def load_data(self, session: Session) -> None: - """ - Same as refresh data, but only used when creating playslit. - Distinguishes profile time between initial load and other - refreshes. - """ - - # We used to clear self.playlist_rows each time but that's - # expensive and slow on big playlists - - # Note where each playlist_id is - plid_to_row: dict[int, int] = {} - for oldrow in self.playlist_rows: - plrdata = self.playlist_rows[oldrow] - plid_to_row[plrdata.playlistrow_id] = plrdata.row_number - - # build a new playlist_rows - new_playlist_rows: dict[int, RowAndTrack] = {} - for p in PlaylistRows.get_playlist_rows(session, self.playlist_id): - if p.id not in plid_to_row: - new_playlist_rows[p.row_number] = RowAndTrack(p) - else: - new_playlist_rows[p.row_number] = self.playlist_rows[plid_to_row[p.id]] - new_playlist_rows[p.row_number].row_number = p.row_number - - # Copy to self.playlist_rows - self.playlist_rows = new_playlist_rows - def mark_unplayed(self, row_numbers: list[int]) -> None: """ Mark row as unplayed """ - with db.Session() as session: - for row_number in row_numbers: - playlist_row = session.get( - PlaylistRows, self.playlist_rows[row_number].playlistrow_id - ) - if not playlist_row: - return - playlist_row.played = False - session.commit() - self.refresh_row(session, row_number) + for row_number in row_numbers: + self.playlist_rows[row_number].played = False + self.refresh_row(row_number) self.update_track_times() # only invalidate required roles @@ -861,75 +799,59 @@ class PlaylistModel(QAbstractTableModel): ] self.invalidate_rows(row_numbers, roles) - def move_rows(self, from_rows: list[int], to_row_number: int) -> None: + # @log_call + def move_rows(self, from_rows: list[int], to_row_number: int) -> bool: """ - Move the playlist rows given to to_row and below. + Move the playlist rows in from_rows to to_row. Return True if successful + else False. """ - log.debug(f"{self}: move_rows({from_rows=}, {to_row_number=}") + log.debug(f"move_rows({from_rows=}, {to_row_number=})") - # Build a {current_row_number: new_row_number} dictionary - row_map: dict[int, int] = {} + if not from_rows: + log.debug("move_rows called with no from_rows") + return False - # The destination row number will need to be reduced by the - # number of rows being move from above the destination row - # otherwise rows below the destination row will end up above the - # moved rows. - adjusted_to_row = to_row_number - len( - [a for a in from_rows if a < to_row_number] - ) + # Don't move current row + if self.track_sequence.current: + current_row = self.track_sequence.current.row_number + if current_row in from_rows: + log.debug("move_rows: Removing {current_row=} from {from_rows=}") + from_rows.remove(self.track_sequence.current.row_number) - # Put the from_row row numbers into the row_map. Ultimately the - # total number of elements in the playlist doesn't change, so - # check that adding the moved rows starting at to_row won't - # overshoot the end of the playlist. - if adjusted_to_row + len(from_rows) > len(self.playlist_rows): - next_to_row = len(self.playlist_rows) - len(from_rows) - else: - next_to_row = adjusted_to_row - - # zip iterates from_row and to_row simultaneously from the - # respective sequences inside zip() - for from_row, to_row in zip( - from_rows, range(next_to_row, next_to_row + len(from_rows)) + from_rows = sorted(set(from_rows)) + if ( + min(from_rows) < 0 + or max(from_rows) >= self.rowCount() + or to_row_number < 0 + or to_row_number > self.rowCount() ): - row_map[from_row] = to_row + log.debug("move_rows: invalid indexes") + return False - # Move the remaining rows to the row_map. We want to fill it - # before (if there are gaps) and after (likewise) the rows that - # are moving. - # zip iterates old_row and new_row simultaneously from the - # respective sequences inside zip() - for old_row, new_row in zip( - [x for x in self.playlist_rows.keys() if x not in from_rows], - [y for y in range(len(self.playlist_rows)) if y not in row_map.values()], - ): - # Optimise: only add to map if there is a change - if old_row != new_row: - row_map[old_row] = new_row + if to_row_number in from_rows: + return False # Destination within rows to be moved - # For SQLAlchemy, build a list of dictionaries that map playlistrow_id to - # new row number: - sqla_map: list[dict[str, int]] = [] - for oldrow, newrow in row_map.items(): - playlistrow_id = self.playlist_rows[oldrow].playlistrow_id - sqla_map.append({"playlistrow_id": playlistrow_id, "row_number": newrow}) - - with db.Session() as session: - PlaylistRows.update_plr_row_numbers(session, self.playlist_id, sqla_map) - session.commit() - # Update playlist_rows - self.refresh_data(session) + # Notify model going to change + self.beginResetModel() + # Update database + ds.playlist_move_rows(from_rows, self.playlist_id, to_row_number) + # Notify model changed + self.endResetModel() # Update display - self.reset_track_sequence_row_numbers() + self.refresh_data() + self.track_sequence.update() self.update_track_times() - # only invalidate required roles - roles = [ - Qt.ItemDataRole.DisplayRole, - ] - self.invalidate_rows(list(row_map.keys()), roles) + # TODO: do we need this? + # # only invalidate required roles + # roles = [ + # Qt.ItemDataRole.DisplayRole, + # ] + # self.invalidate_rows(list(row_map.keys()), roles) + return True + # @log_call def move_rows_between_playlists( self, from_rows: list[int], @@ -940,118 +862,91 @@ class PlaylistModel(QAbstractTableModel): Move the playlist rows given to to_row and below of to_playlist. """ - log.debug( - f"{self}: move_rows_between_playlists({from_rows=}, " - f"{to_row_number=}, {to_playlist_id=}" - ) + # Don't move current row + if self.track_sequence.current: + current_row = self.track_sequence.current.row_number + if current_row in from_rows: + log.debug( + "move_rows_between_playlists: Removing {current_row=} from {from_rows=}" + ) + from_rows.remove(self.track_sequence.current.row_number) + + # Row removal must be wrapped in beginRemoveRows .. endRemoveRows + # and the row range must be contiguous. Process the highest rows + # first so the lower row numbers are unchanged - # Row removal must be wrapped in beginRemoveRows .. - # endRemoveRows and the row range must be contiguous. Process - # the highest rows first so the lower row numbers are unchanged row_groups = self._reversed_contiguous_row_groups(from_rows) - # Prepare destination playlist for a reset - self.signals.begin_reset_model_signal.emit(to_playlist_id) + # Handle the moves in row_group chunks - with db.Session() as session: - for row_group in row_groups: - # Make room in destination playlist - max_destination_row_number = PlaylistRows.get_last_used_row( - session, to_playlist_id - ) - if ( - max_destination_row_number - and to_row_number <= max_destination_row_number - ): - # Move the destination playlist rows down to make room. - PlaylistRows.move_rows_down( - session, to_playlist_id, to_row_number, len(row_group) - ) - next_to_row = to_row_number + for row_group in row_groups: + # Prepare source model + super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group)) + # Prepare destination model + insert_rows = InsertRows( + to_playlist_id, to_row_number, to_row_number + len(row_group) + ) + self.signals.signal_begin_insert_rows.emit(insert_rows) + ds.playlist_move_rows( + from_rows=row_group, + from_playlist_id=self.playlist_id, + to_row=to_row_number, + to_playlist_id=to_playlist_id, + ) + self.signals.signal_end_insert_rows.emit(to_playlist_id) + super().endRemoveRows() - super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group)) - for playlist_row in PlaylistRows.plrids_to_plrs( - session, - self.playlist_id, - [self.playlist_rows[a].playlistrow_id for a in row_group], - ): - if ( - track_sequence.current - and playlist_row.id == track_sequence.current.playlistrow_id - ): - # Don't move current track - continue - playlist_row.playlist_id = to_playlist_id - playlist_row.row_number = next_to_row - next_to_row += 1 - self.refresh_data(session) - super().endRemoveRows() - # We need to remove gaps in row numbers after tracks have - # moved. - PlaylistRows.fixup_rownumbers(session, self.playlist_id) - self.refresh_data(session) - session.commit() - - # Reset of model must come after session has been closed - self.reset_track_sequence_row_numbers() - self.signals.end_reset_model_signal.emit(to_playlist_id) + self.refresh_data() + self.track_sequence.update() self.update_track_times() + def begin_insert_rows_handler(self, insert_rows: InsertRows) -> None: + """ + Prepare model to insert rows + """ + + if insert_rows.playlist_id != self.playlist_id: + return + + super().beginInsertRows(QModelIndex(), insert_rows.from_row, insert_rows.to_row) + + def end_insert_rows_handler(self, playlist_id: int) -> None: + """ + End insert rows + """ + + if playlist_id != self.playlist_id: + return + + super().endInsertRows() + self.refresh_data() + + # @log_call def move_track_add_note( - self, new_row_number: int, existing_rat: RowAndTrack, note: str + self, new_row_number: int, existing_plr: PlaylistRow, note: str ) -> None: """ Move existing_rat track to new_row_number and append note to any existing note """ - log.debug( - f"{self}: move_track_add_note({new_row_number=}, {existing_rat=}, {note=}" - ) - if note: - with db.Session() as session: - playlist_row = session.get(PlaylistRows, existing_rat.playlistrow_id) - if playlist_row: - if playlist_row.note: - playlist_row.note += "\n" + note - else: - playlist_row.note = note - self.refresh_row(session, playlist_row.row_number) - session.commit() + playlist_row = self.playlist_rows[existing_plr.row_number] + if playlist_row.note: + playlist_row.note += "\n" + note + else: + playlist_row.note = note + self.refresh_row(existing_plr.row_number) - # Carry out the move outside of the session context to ensure - # 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) - def move_track_to_header( - self, - header_row_number: int, - existing_rat: RowAndTrack, - note: Optional[str], - ) -> None: - """ - Add the existing_rat track details to the existing header at header_row_number - """ - - log.debug( - f"{self}: move_track_to_header({header_row_number=}, {existing_rat=}, {note=}" - ) - - if existing_rat.track_id: - if note and existing_rat.note: - note += "\n" + existing_rat.note - self.add_track_to_header(header_row_number, existing_rat.track_id, note) - self.delete_rows([existing_rat.row_number]) - + # @log_call def obs_scene_change(self, row_number: int) -> None: """ Check this row and any preceding headers for OBS scene change command and execute any found """ - log.debug(f"{self}: obs_scene_change({row_number=})") - # Check any headers before this row idx = row_number - 1 while self.is_header_row(idx): @@ -1080,27 +975,30 @@ class PlaylistModel(QAbstractTableModel): log.warning(f"{self}: OBS connection refused") return - def previous_track_ended(self) -> None: + # @log_call + def signal_track_ended_handler(self, playlist_id: int) -> None: """ - Notification from musicmuster that the previous track has ended. + Notification from signal_track_ended that the previous track has ended. Actions required: - sanity check - update display """ - log.debug(f"{self}: previous_track_ended()") + if playlist_id != self.playlist_id: + # Not for us + return # Sanity check - if not track_sequence.previous: + if not self.track_sequence.previous: log.error( - f"{self}: playlistmodel:previous_track_ended called with no current track" + f"{self}: playlistmodel:signal_track_ended_handler called with no current track" ) return - if track_sequence.previous.row_number is None: + if self.track_sequence.previous.row_number is None: log.error( - f"{self}: previous_track_ended called with no row number " - f"({track_sequence.previous=})" + f"{self}: signal_track_ended_handler called with no row number " + f"({self.track_sequence.previous=})" ) return @@ -1109,59 +1007,53 @@ class PlaylistModel(QAbstractTableModel): roles = [ 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) -> None: """ Populate self.playlist_rows with playlist data - - We used to clear self.playlist_rows each time but that's - expensive and slow on big playlists. Instead we track where rows - are in database versus self.playlist_rows and fixup the latter. - This works well for news rows added and for rows moved, but - doesn't work for changed comments so they must be handled using - refresh_row(). """ - # Note where each playlist_id is - plid_to_row: dict[int, int] = {} + # Note where each playlist_id is by mapping each playlistrow_id + # to its current row_number + plrid_to_row: dict[int, int] = {} for oldrow in self.playlist_rows: plrdata = self.playlist_rows[oldrow] - plid_to_row[plrdata.playlistrow_id] = plrdata.row_number + plrid_to_row[plrdata.playlistrow_id] = plrdata.row_number # build a new playlist_rows - new_playlist_rows: dict[int, RowAndTrack] = {} - for p in PlaylistRows.get_playlist_rows(session, self.playlist_id): - if p.id not in plid_to_row: - new_playlist_rows[p.row_number] = RowAndTrack(p) + new_playlist_rows: dict[int, PlaylistRow] = {} + for dto in ds.playlistrows_by_playlist(self.playlist_id): + if dto.playlistrow_id not in plrid_to_row: + new_playlist_rows[dto.row_number] = PlaylistRow(dto) else: - new_playlist_rows[p.row_number] = self.playlist_rows[plid_to_row[p.id]] - new_playlist_rows[p.row_number].row_number = p.row_number + new_playlist_row = self.playlist_rows[plrid_to_row[dto.playlistrow_id]] + new_playlist_row.row_number = dto.row_number + new_playlist_rows[dto.row_number] = new_playlist_row # Copy to self.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""" - p = PlaylistRows.deep_row(session, self.playlist_id, row_number) - self.playlist_rows[row_number] = RowAndTrack(p) + plrid = self.playlist_rows[row_number].playlistrow_id + refreshed_row = ds.playlistrow_by_id(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) + + # @log_call def remove_track(self, row_number: int) -> None: """ Remove track from row, retaining row as a header row """ - log.debug(f"{self}: remove_track({row_number=})") + self.playlist_rows[row_number].track_id = 0 - with db.Session() as session: - 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 roles = [ Qt.ItemDataRole.DisplayRole, @@ -1173,22 +1065,19 @@ class PlaylistModel(QAbstractTableModel): Rescan track at passed row number """ - track_id = self.playlist_rows[row_number].track_id - if track_id: - with db.Session() as session: - track = session.get(Tracks, track_id) - set_track_metadata(track) - self.refresh_row(session, row_number) - self.update_track_times() - roles = [ - Qt.ItemDataRole.BackgroundRole, - Qt.ItemDataRole.DisplayRole, - ] - # only invalidate required roles - self.invalidate_row(row_number, roles) - self.signals.resize_rows_signal.emit(self.playlist_id) - session.commit() + track = self.playlist_rows[row_number] + metadata = get_all_track_metadata(track.path) + _ = ds.track_update(track.track_id, metadata) + roles = [ + Qt.ItemDataRole.BackgroundRole, + Qt.ItemDataRole.DisplayRole, + ] + # only invalidate required roles + self.invalidate_row(row_number, roles) + self.signals.resize_rows_signal.emit(self.playlist_id) + + # @log_call def reset_track_sequence_row_numbers(self) -> None: """ Signal handler for when row ordering has changed. @@ -1199,19 +1088,7 @@ class PlaylistModel(QAbstractTableModel): 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.track_sequence.update() self.update_track_times() @@ -1230,21 +1107,8 @@ class PlaylistModel(QAbstractTableModel): ): return - with db.Session() as session: - for row_number in row_numbers: - playlist_row = session.get( - PlaylistRows, self.playlist_rows[row_number].playlistrow_id - ) - if playlist_row.track_id: - playlist_row.note = "" - # We can't use refresh_data() because its - # optimisations mean it won't update comments in - # self.playlist_rows - # The "correct" approach would be to re-read from the - # database but we optimise here by simply updating - # self.playlist_rows directly. - self.playlist_rows[row_number].note = "" - session.commit() + ds.playlist_remove_comments(self.playlist_id, row_numbers) + # only invalidate required roles roles = [ Qt.ItemDataRole.BackgroundRole, @@ -1253,6 +1117,7 @@ class PlaylistModel(QAbstractTableModel): ] self.invalidate_rows(row_numbers, roles) + # @log_call def _reversed_contiguous_row_groups( self, row_numbers: list[int] ) -> list[list[int]]: @@ -1265,11 +1130,10 @@ class PlaylistModel(QAbstractTableModel): return: [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]] """ - log.debug(f"{self}: _reversed_contiguous_row_groups({row_numbers=} called") - result: list[list[int]] = [] temp: list[int] = [] last_value = row_numbers[0] - 1 + row_numbers.sort() for idx in range(len(row_numbers)): if row_numbers[idx] != last_value + 1: @@ -1281,12 +1145,11 @@ class PlaylistModel(QAbstractTableModel): result.append(temp) result.reverse() - log.debug(f"{self}: _reversed_contiguous_row_groups() returned: {result=}") return result def remove_section_timer_markers(self, header_text: str) -> str: """ - Remove characters used to mark section timeings from + Remove characters used to mark section timings from passed header text. Remove text using to signal header colours if colour entry @@ -1303,38 +1166,14 @@ class PlaylistModel(QAbstractTableModel): header_text = header_text[0:-1] # Parse passed header text and remove the first colour match string - with db.Session() as session: - for rec in NoteColours.get_all(session): - if not rec.strip_substring: - continue - if rec.is_regex: - flags = re.UNICODE - if not rec.is_casesensitive: - flags |= re.IGNORECASE - p = re.compile(rec.substring, flags) - if p.match(header_text): - header_text = re.sub(p, "", header_text) - break - else: - if rec.is_casesensitive: - if rec.substring.lower() in header_text.lower(): - header_text = remove_substring_case_insensitive( - header_text, rec.substring - ) - break - else: - if rec.substring in header_text: - header_text = header_text.replace(rec.substring, "") - break - - return header_text + return ds.notecolours_remove_colour_substring(header_text) def rowCount(self, index: QModelIndex = QModelIndex()) -> int: """Standard function for view""" return len(self.playlist_rows) - def section_subtotal_header(self, rat: RowAndTrack) -> str: + def section_subtotal_header(self, plr: PlaylistRow) -> str: """ Process this row as subtotal within a timed section and return display text for this row @@ -1344,12 +1183,12 @@ class PlaylistModel(QAbstractTableModel): unplayed_count: int = 0 duration: int = 0 - if rat.row_number == 0: + if plr.row_number == 0: # Meaningless to have a subtotal on row 0 return Config.SUBTOTAL_ON_ROW_ZERO # Show subtotal - for row_number in range(rat.row_number - 1, -1, -1): + for row_number in range(plr.row_number - 1, -1, -1): row_rat = self.playlist_rows[row_number] if self.is_header_row(row_number) or row_number == 0: if row_rat.note.endswith(Config.SECTION_STARTS) or row_number == 0: @@ -1357,23 +1196,23 @@ class PlaylistModel(QAbstractTableModel): # calculate end time when all tracks are played. end_time_str = "" if ( - track_sequence.current - and track_sequence.current.end_time + self.track_sequence.current + and self.track_sequence.current.end_time and ( row_number - < track_sequence.current.row_number - < rat.row_number + < self.track_sequence.current.row_number + < plr.row_number ) ): section_end_time = ( - track_sequence.current.end_time + self.track_sequence.current.end_time + dt.timedelta(milliseconds=duration) ) end_time_str = ( ", section end time " + section_end_time.strftime(Config.TRACK_TIME_FORMAT) ) - clean_header = self.remove_section_timer_markers(rat.note) + clean_header = self.remove_section_timer_markers(plr.note) if clean_header: return ( f"{clean_header} [" @@ -1419,59 +1258,71 @@ class PlaylistModel(QAbstractTableModel): return True - def set_next_row(self, row_number: Optional[int]) -> None: + # @log_call + def playlist_selected_rows_handler(self, selected_rows: SelectedRows) -> None: """ - Set row_number as next track. If row_number is None, clear next track. - - Return True if successful else False. + Handle signal_playlist_selected_rows to keep track of which rows + are selected in the view """ - log.debug(f"{self}: set_next_row({row_number=})") + if selected_rows.playlist_id != self.playlist_id: + return - if row_number is None: - # Clear next track - if track_sequence.next is not None: - track_sequence.set_next(None) - else: - # Get playlistrow_id of row - try: - rat = self.playlist_rows[row_number] - except IndexError: - log.error(f"{self} set_track_sequence.next({row_number=}, IndexError") - return - if rat.track_id is None or rat.row_number is None: - log.error( - f"{self} .set_track_sequence.next({row_number=}, " - f"No track / row number {rat.track_id=}, {rat.row_number=}" - ) - return + self.selected_rows = [self.playlist_rows[a] for a in selected_rows.rows] - old_next_row: Optional[int] = None - if track_sequence.next: - old_next_row = track_sequence.next.row_number + # @log_call + def set_next_row_handler(self, playlist_id: int) -> None: + """ + Handle signal_set_next_row + """ - track_sequence.set_next(rat) + if playlist_id != self.playlist_id: + return - if Config.WIKIPEDIA_ON_NEXT: - self.signals.search_wikipedia_signal.emit( - self.playlist_rows[row_number].title - ) - if Config.SONGFACTS_ON_NEXT: - self.signals.search_songfacts_signal.emit( - self.playlist_rows[row_number].title - ) - roles = [ - Qt.ItemDataRole.BackgroundRole, - ] - if old_next_row is not None: - # only invalidate required roles - self.invalidate_row(old_next_row, roles) + if len(self.selected_rows) == 0: + # No row selected so clear next track + if self.track_sequence.next is not None: + self.track_sequence.set_next(None) + return + + if len(self.selected_rows) > 1: + self.signals.show_warning_signal.emit( + "Too many rows selected", "Select one row for next row" + ) + return + + plr = self.selected_rows[0] + if plr.track_id is None: + raise ApplicationError(f"set_next_row: no track_id ({plr=})") + + old_next_row: int | None = None + if self.track_sequence.next: + old_next_row = self.track_sequence.next.row_number + + roles = [ + Qt.ItemDataRole.BackgroundRole, + ] + if old_next_row is not None: # only invalidate required roles - self.invalidate_row(row_number, roles) + self.invalidate_row(old_next_row, roles) + # only invalidate required roles + self.invalidate_row(plr.row_number, roles) + + self.signals.signal_set_next_track.emit(plr) + + def next_track_changed_handler(self) -> None: + """ + Handle next track changed + """ - self.signals.next_track_changed_signal.emit() self.update_track_times() + # Refresh display to show new next track + if self.track_sequence.next: + next_row_number = self.track_sequence.next.row_number + if next_row_number is not None: + self.invalidate_row(next_row_number, [Qt.ItemDataRole.BackgroundRole]) + # @log_call def setData( self, index: QModelIndex, @@ -1479,7 +1330,10 @@ class PlaylistModel(QAbstractTableModel): role: int = Qt.ItemDataRole.EditRole, ) -> bool: """ - Update model with edited data + Update model with edited data. Here we simply update the + playlist_row in self.playlist_rows. The act of doing that will + trigger a database update in the @setter property in the + PlaylistRow class. """ if not index.isValid() or role != Qt.ItemDataRole.EditRole: @@ -1487,46 +1341,28 @@ class PlaylistModel(QAbstractTableModel): row_number = index.row() column = index.column() + plr = self.playlist_rows[row_number] - with db.Session() as session: - playlist_row = session.get( - PlaylistRows, self.playlist_rows[row_number].playlistrow_id - ) - if not playlist_row: - log.error( - f"{self}: Error saving data: {row_number=}, {column=}, {value=}" - ) - return False + if column == Col.NOTE.value: + plr.note = str(value) - if playlist_row.track_id: - if column in [Col.TITLE.value, Col.ARTIST.value, Col.INTRO.value]: - track = session.get(Tracks, playlist_row.track_id) - if not track: - log.error(f"{self}: Error retreiving track: {playlist_row=}") - return False - if column == Col.TITLE.value: - track.title = str(value) - elif column == Col.ARTIST.value: - track.artist = str(value) - elif column == Col.INTRO.value: - track.intro = int(round(float(value), 1) * 1000) - else: - log.error(f"{self}: Error updating track: {column=}, {value=}") - return False - elif column == Col.NOTE.value: - playlist_row.note = str(value) + elif column == Col.TITLE.value: + plr.title = str(value) - else: - # This is a header row - if column == HEADER_NOTES_COLUMN: - playlist_row.note = str(value) + elif column == Col.ARTIST.value: + plr.artist = str(value) - # commit changes before refreshing data - session.commit() - self.refresh_row(session, row_number) - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role]) + elif column == Col.INTRO.value: + intro = int(round(float(value), 1) * 1000) + plr.intro = intro - return True + else: + raise ApplicationError(f"setData called with unexpected column ({column=})") + + self.refresh_row(row_number) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role]) + + return True def sort_by_artist(self, row_numbers: list[int]) -> None: """ @@ -1581,7 +1417,7 @@ class PlaylistModel(QAbstractTableModel): self.sort_by_attribute(row_numbers, "title") - def start_of_timed_section_header(self, rat: RowAndTrack) -> str: + def start_of_timed_section_header(self, plr: PlaylistRow) -> str: """ Process this row as the start of a timed section and return display text for this row @@ -1591,9 +1427,9 @@ class PlaylistModel(QAbstractTableModel): unplayed_count: int = 0 duration: int = 0 - clean_header = self.remove_section_timer_markers(rat.note) + clean_header = self.remove_section_timer_markers(plr.note) - for row_number in range(rat.row_number + 1, len(self.playlist_rows)): + for row_number in range(plr.row_number + 1, len(self.playlist_rows)): row_rat = self.playlist_rows[row_number] if self.is_header_row(row_number): if row_rat.note.endswith(Config.SECTION_ENDINGS): @@ -1617,25 +1453,21 @@ class PlaylistModel(QAbstractTableModel): def supportedDropActions(self) -> Qt.DropAction: return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction - def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str: + def _tooltip_role(self, row: int, column: int, plr: PlaylistRow) -> str: """ Return tooltip. Currently only used for last_played column. """ if column != Col.LAST_PLAYED.value: return "" - with db.Session() as session: - track_id = self.playlist_rows[row].track_id - if not track_id: - return "" - playdates = Playdates.last_playdates(session, track_id) - return "
".join( - [ - a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) - for a in playdates - ] - ) + track_id = self.playlist_rows[row].track_id + if not track_id: + return "" + + return ds.playdates_get_last(track_id) + + # @log_call def update_or_insert(self, track_id: int, row_number: int) -> None: """ If the passed track_id exists in this playlist, update the @@ -1646,9 +1478,8 @@ class PlaylistModel(QAbstractTableModel): a.row_number for a in self.playlist_rows.values() if a.track_id == track_id ] if track_rows: - with db.Session() as session: - for row in track_rows: - self.refresh_row(session, row) + for row in track_rows: + self.refresh_row(row) # only invalidate required roles roles = [ Qt.ItemDataRole.BackgroundRole, @@ -1658,74 +1489,134 @@ class PlaylistModel(QAbstractTableModel): ] self.invalidate_rows(track_rows, roles) else: - self.insert_row(proposed_row_number=row_number, track_id=track_id) + self.insert_row_signal_handler( + InsertTrack(playlist_id=self.playlist_id, track_id=track_id, note="") + ) + def get_end_time(self, row_number: int, start_time: dt.datetime) -> dt.datetime: + """ + Return the end time for row_number from the passed start_time and + the row duration. + """ + + plr = self.playlist_rows[row_number] + end_time = start_time + dt.timedelta(milliseconds=plr.duration) + + return end_time + + def update_start_end_times( + self, plr: PlaylistRow, start_time: dt.datetime, end_time: dt.datetime + ) -> bool: + """ + Set the the start time of the passed PlaylistRow. If we changed + it, return True else return False. + """ + + changed = False + + if start_time != plr.forecast_start_time: + plr.forecast_start_time = start_time + changed = True + if end_time != plr.forecast_end_time: + plr.forecast_end_time = end_time + changed = True + + return changed + + # @log_call def update_track_times(self) -> None: """ Update track start/end times in self.playlist_rows """ - log.debug(f"issue285: {self}: update_track_times()") - - next_start_time: Optional[dt.datetime] = None + next_start_time: dt.datetime | None = None update_rows: list[int] = [] + + current_track_row_number: int | None = None + next_track_row: int | None = None + row_count = len(self.playlist_rows) - current_track_row = None - next_track_row = None + # If we have a current track, get its end time if ( - track_sequence.current - and track_sequence.current.playlist_id == self.playlist_id + self.track_sequence.current + and self.track_sequence.current.playlist_id == self.playlist_id ): - current_track_row = track_sequence.current.row_number - # Update current track details now so that they are available - # when we deal with next track row which may be above current - # track row. - self.playlist_rows[current_track_row].set_forecast_start_time( - update_rows, track_sequence.current.start_time + plr = self.track_sequence.current + current_track_row_number = plr.row_number + current_track_start_time = self.track_sequence.current.start_time + if current_track_start_time is None: + raise ApplicationError( + f"Can't get start time for current track ({self.track_sequence.current=})" + ) + current_track_end_time = self.get_end_time( + current_track_row_number, current_track_start_time ) - if track_sequence.next and track_sequence.next.playlist_id == self.playlist_id: - next_track_row = track_sequence.next.row_number + if self.update_start_end_times( + plr, current_track_start_time, current_track_end_time + ): + update_rows.append(current_track_row_number) + # If we have a next track, note row number + if ( + self.track_sequence.next + and self.track_sequence.next.playlist_id == self.playlist_id + ): + next_track_row = self.track_sequence.next.row_number + + # Step through rows and update start/end times for row_number in range(row_count): - rat = self.playlist_rows[row_number] + plr = self.playlist_rows[row_number] + + # Don't update times for tracks that have been played unless + # this is the next track, for unreadable tracks or for the + # current track, handled above. - # Don't update times for tracks that have been played, for - # unreadable tracks or for the current track, handled above. if ( - rat.played - or row_number == current_track_row - or (rat.path and file_is_unreadable(rat.path)) + (plr.played and row_number != next_track_row) + or row_number == current_track_row_number + or (plr.path and file_is_unreadable(plr.path)) ): continue - # Reset start time if timing in header + # Reset start time if timing in header; otherwise skip header if self.is_header_row(row_number): - header_time = get_embedded_time(rat.note) + header_time = get_embedded_time(plr.note) if header_time: next_start_time = header_time continue # Set start time for next row if we have a current track - if ( - row_number == next_track_row - and track_sequence.current - and track_sequence.current.end_time - ): - next_start_time = rat.set_forecast_start_time( - update_rows, track_sequence.current.end_time - ) - continue + if current_track_row_number is not None and row_number == next_track_row: + next_start_time = self.get_end_time(row_number, current_track_end_time) + if self.update_start_end_times( + plr, current_track_end_time, next_start_time + ): + update_rows.append(row_number) # If we're between the current and next row, zero out # times - if (current_track_row or row_count) < row_number < (next_track_row or 0): - rat.set_forecast_start_time(update_rows, None) + if ( + (current_track_row_number or row_count) + < row_number + < (next_track_row or 0) + ): + plr.forecast_start_time = plr.forecast_end_time = None + update_rows.append(row_number) + continue + + # If we don't have a start time, keep looking + if next_start_time is None: continue # Set start/end - next_start_time = rat.set_forecast_start_time(update_rows, next_start_time) + plr = self.playlist_rows[row_number] + start_time = next_start_time + next_start_time = self.get_end_time(row_number, next_start_time) + if self.update_start_end_times(plr, start_time, next_start_time): + update_rows.append(row_number) + continue # Update start/stop times of rows that have changed for updated_row in update_rows: @@ -1748,6 +1639,8 @@ class PlaylistProxyModel(QSortFilterProxyModel): # Search all columns self.setFilterKeyColumn(-1) + self.track_sequence = TrackSequence() + def __repr__(self) -> str: return f"" @@ -1763,39 +1656,46 @@ class PlaylistProxyModel(QSortFilterProxyModel): if self.sourceModel().is_played_row(source_row): # Don't hide current track if ( - track_sequence.current - and track_sequence.current.playlist_id + self.track_sequence.current + and self.track_sequence.current.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 # Don't hide next track if ( - track_sequence.next - and track_sequence.next.playlist_id + self.track_sequence.next + and self.track_sequence.next.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 # Handle previous track - if track_sequence.previous: + if self.track_sequence.previous: if ( - track_sequence.previous.playlist_id + self.track_sequence.previous.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 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 # until HIDE_AFTER_PLAYING_OFFSET milliseconds # after current track has started - if track_sequence.current.start_time and dt.datetime.now() > ( - track_sequence.current.start_time - + dt.timedelta( - milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET + if ( + self.track_sequence.current.start_time + and dt.datetime.now() + > ( + self.track_sequence.current.start_time + + dt.timedelta( + milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET + ) ) ): return False @@ -1811,7 +1711,9 @@ class PlaylistProxyModel(QSortFilterProxyModel): ] QTimer.singleShot( Config.HIDE_AFTER_PLAYING_OFFSET + 100, - lambda: self.sourceModel().invalidate_row(source_row, roles), + lambda: self.sourceModel().invalidate_row( + source_row, roles + ), ) return True # Next track not playing yet so don't hide previous diff --git a/app/playlistrow.py b/app/playlistrow.py new file mode 100644 index 0000000..ba3bb47 --- /dev/null +++ b/app/playlistrow.py @@ -0,0 +1,546 @@ +# Standard library imports +from collections import deque +import datetime as dt + +# 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 +from log import log +from music_manager import Music +import ds +import helpers + + +class FadeGraphGenerator(QObject): + finished = pyqtSignal(object, object) + task_completed = pyqtSignal() + + def generate_graph(self, plr: "PlaylistRow") -> None: + fade_graph = FadeCurve(plr.path, plr.fade_at, plr.silence_at) + if not fade_graph: + log.error(f"Failed to create FadeCurve for {plr=}") + return + + self.finished.emit(plr, fade_graph) + self.task_completed.emit() + + +@singleton +class FadegraphThreadController(QObject): + def __init__(self): + super().__init__() + self._thread = None + self._generator = None + self._request_queue = deque() + + def generate_fade_graph(self, playlist_row): + self._request_queue.append(playlist_row) # Use append for enqueue with deque + if self._thread is None or not self._thread.isRunning(): + self._start_next_generation() + + def _start_next_generation(self): + if not self._request_queue: # Check if deque is empty + return + playlist_row = self._request_queue.popleft() # Use popleft for dequeue with deque + self._start_thread(playlist_row) + + def _start_thread(self, playlist_row): + self._thread = QThread() + self._generator = FadeGraphGenerator() + self._generator.moveToThread(self._thread) + self._generator.finished.connect(lambda row, graph: row.attach_fade_graph(graph)) + self._generator.task_completed.connect(self._cleanup_thread) + self._thread.started.connect(lambda: self._generator.generate_graph(playlist_row)) + self._thread.start() + + def _cleanup_thread(self): + if self._thread: + self._thread.quit() + self._thread.wait() + self._thread.deleteLater() + self._thread = None + self._generator.deleteLater() + self._generator = None + # Start the next request if any + self._start_next_generation() + + +class PlaylistRow: + """ + Object to manage playlist row and track. + """ + + def __init__(self, dto: PlaylistRowDTO) -> None: + """ + The dto object will include row information plus 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: FadeCurve | 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 + self.fadegraph_thread_controller = FadegraphThreadController() + + def __repr__(self) -> str: + track_id = None + if self.dto.track: + track_id = self.dto.track.track_id + return ( + f"" + ) + + # Expose TrackDTO fields as properties + @property + def artist(self) -> str: + if self.dto.track: + return self.dto.track.artist + else: + return "" + + @artist.setter + def artist(self, artist: str) -> None: + if not self.dto.track: + raise ApplicationError(f"No track_id when trying to set artist ({self})") + + self.dto.track.artist = artist + ds.track_update(self.track_id, dict(artist=str(artist))) + + @property + def bitrate(self) -> int: + if self.dto.track: + return self.dto.track.bitrate + else: + return 0 + + @property + def duration(self) -> int: + if self.dto.track: + return self.dto.track.duration + else: + return 0 + + @property + def fade_at(self) -> int: + if self.dto.track: + return self.dto.track.fade_at + else: + return 0 + + @property + def intro(self) -> int: + if self.dto.track: + return self.dto.track.intro or 0 + else: + return 0 + + @intro.setter + def intro(self, intro: int) -> None: + if not self.dto.track: + raise ApplicationError(f"No track_id when trying to set intro ({self})") + + self.dto.track.intro = intro + ds.track_update(self.track_id, dict(intro=str(intro))) + + @property + def lastplayed(self) -> dt.datetime | None: + if self.dto.track: + return self.dto.track.lastplayed + else: + return None + + @property + def path(self) -> str: + if self.dto.track: + return self.dto.track.path + else: + return "" + + @property + def silence_at(self) -> int: + if self.dto.track: + return self.dto.track.silence_at + else: + return 0 + + @property + def start_gap(self) -> int: + if self.dto.track: + return self.dto.track.start_gap + else: + return 0 + + @property + def title(self) -> str: + if self.dto.track: + return self.dto.track.title + else: + return "" + + @title.setter + def title(self, title: str) -> None: + if not self.dto.track: + raise ApplicationError(f"No track_id when trying to set title ({self})") + + self.dto.track.title = title + ds.track_update(self.track_id, dict(title=str(title))) + + @property + def track_id(self) -> int: + if self.dto.track: + return self.dto.track.track_id + else: + return 0 + + @track_id.setter + def track_id(self, track_id: int) -> None: + """ + Adding a track_id should only happen to a header row. + """ + + if self.track_id > 0: + raise ApplicationError( + "Attempting to add track to row with existing track ({self=}" + ) + + ds.track_add_to_header(playlistrow_id=self.playlistrow_id, track_id=track_id) + + # Need to update with track information + track = ds.track_by_id(track_id) + if track: + for attr, value in track.__dataclass_fields__.items(): + setattr(self, attr, value) + + # Expose PlaylistRowDTO fields as properties + @property + def note(self) -> str: + return self.dto.note + + @note.setter + def note(self, note: str) -> None: + self.dto.note = note + ds.playlistrow_update_note(self.playlistrow_id, str(note)) + + @property + def played(self) -> bool: + return self.dto.played + + @played.setter + def played(self, value: bool) -> None: + self.dto.played = True + ds.playlistrow_played(self.playlistrow_id, value) + + @property + def playlist_id(self) -> int: + return self.dto.playlist_id + + @property + def playlistrow_id(self) -> int: + return self.dto.playlistrow_id + + @property + def row_number(self) -> int: + return self.dto.row_number + + @row_number.setter + def row_number(self, value: int) -> None: + # This does not update the database. The only times the row + # number changes are 1) in ds._playlist_check_playlist and + # ds.playlist_move_rows, and in both those places ds saves + # the change to the database. + self.dto.row_number = value + + def attach_fade_graph(self, fade_graph): + self.fade_graph = fade_graph + + 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) + + def play(self, position: float | None = None) -> None: + """Play track""" + + now = dt.datetime.now() + self.start_time = now + + # Initialise player + self.music.play( + path=self.path, + start_time=now, + playlist_id=self.playlist_id, + 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 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.music.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 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 = max( + 0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1 + ) + self.end_ms = 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 + plr.fadegraph_thread_controller.generate_fade_graph(plr) + + 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 + if self.current.fade_graph: + self.current.fade_graph.clear() + self.current.fade_graph = None + self.previous = self.current + self.current = None + self.start_time = None + + def move_previous_to_next(self) -> None: + """ + Make the previous track the next track + """ + + self.next = self.previous + self.previous = None + + 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 = ds.playlistrow_by_id(ts.playlistrow_id) + if not playlist_row_dto: + raise ApplicationError(f"(Can't retrieve PlaylistRows entry, {self=}") + ts = PlaylistRow(playlist_row_dto) diff --git a/app/playlists.py b/app/playlists.py index 4dad5fc..028b688 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -34,9 +34,16 @@ from PyQt6.QtWidgets import ( # import line_profiler # App imports -from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo +from classes import ( + ApplicationError, + Col, + MusicMusterSignals, + PlaylistStyle, + SelectedRows, + TrackInfo, +) from config import Config -from dialogs import TrackSelectDialog +from dialogs import TrackInsertDialog from helpers import ( ask_yes_no, ms_to_mmss, @@ -44,9 +51,9 @@ from helpers import ( show_warning, ) from log import log, log_call -from models import db, Settings -from music_manager import track_sequence +from playlistrow import TrackSequence from playlistmodel import PlaylistModel, PlaylistProxyModel +import ds if TYPE_CHECKING: from musicmuster import Window @@ -182,9 +189,7 @@ class PlaylistDelegate(QStyledItemDelegate): # Close editor if no changes have been made data_modified = False if isinstance(editor, QTextEdit): - data_modified = ( - self.original_model_data != editor.toPlainText() - ) + data_modified = self.original_model_data != editor.toPlainText() elif isinstance(editor, QDoubleSpinBox): data_modified = ( self.original_model_data != int(editor.value()) * 1000 @@ -277,6 +282,7 @@ class PlaylistTab(QTableView): self.musicmuster = musicmuster self.playlist_id = model.sourceModel().playlist_id + self.track_sequence = TrackSequence() # Set up widget self.setItemDelegate(PlaylistDelegate(self, model.sourceModel())) @@ -300,8 +306,9 @@ class PlaylistTab(QTableView): # Connect signals self.signals = MusicMusterSignals() - self.signals.resize_rows_signal.connect(self.resize_rows) - self.signals.span_cells_signal.connect(self._span_cells) + self.signals.resize_rows_signal.connect(self.resize_rows_handler) + self.signals.span_cells_signal.connect(self._span_cells_handler) + self.signals.signal_track_started.connect(self.track_started_handler) # Selection model self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) @@ -325,7 +332,7 @@ class PlaylistTab(QTableView): v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents) # Setting ResizeToContents causes screen flash on load - self.resize_rows() + self.resize_rows_handler() # ########## Overridden class functions ########## @@ -336,12 +343,12 @@ class PlaylistTab(QTableView): Override closeEditor to enable play controls and update display. """ - self.musicmuster.enable_escape(True) + self.signals.enable_escape_signal.emit(True) super(PlaylistTab, self).closeEditor(editor, hint) # Optimise row heights after increasing row height for editing - self.resize_rows() + self.resize_rows_handler() # Update start times in case a start time in a note has been # edited @@ -350,7 +357,8 @@ class PlaylistTab(QTableView): # Deselect edited line self.clear_selection() - def dropEvent(self, event: Optional[QDropEvent], dummy: int | None = None) -> None: + # @log_call + def dropEvent(self, event: Optional[QDropEvent]) -> None: """ Move dropped rows """ @@ -386,9 +394,6 @@ class PlaylistTab(QTableView): destination_index = to_index to_model_row = self.model().mapToSource(destination_index).row() - log.debug( - f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}" - ) # Sanity check base_model_row_count = self.get_base_model().rowCount() @@ -400,8 +405,8 @@ class PlaylistTab(QTableView): # that moved row the next track set_next_row: Optional[int] = None if ( - track_sequence.current - and to_model_row == track_sequence.current.row_number + 1 + self.track_sequence.current + and to_model_row == self.track_sequence.current.row_number + 1 ): set_next_row = to_model_row @@ -414,11 +419,11 @@ class PlaylistTab(QTableView): self.clear_selection() # Resize rows - self.resize_rows() + self.resize_rows_handler() # Set next row if we are immediately under current row if set_next_row: - self.get_base_model().set_next_row(set_next_row) + self.get_base_model().set_next_row_handler(set_next_row) event.accept() @@ -448,14 +453,21 @@ class PlaylistTab(QTableView): self, selected: QItemSelection, deselected: QItemSelection ) -> None: """ + Tell model which rows are selected. + Toggle drag behaviour according to whether rows are selected """ - selected_rows = self.get_selected_rows() - self.musicmuster.current.selected_rows = selected_rows + selected_row_numbers = self.get_selected_rows() + # Signal selected rows to model + self.signals.signal_playlist_selected_rows.emit( + SelectedRows(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 len(selected_rows) == 0: + if len(selected_row_numbers) == 0: self.musicmuster.lblSumPlaytime.setText("") else: if not self.musicmuster.disable_selection_timing: @@ -499,22 +511,16 @@ class PlaylistTab(QTableView): return menu_item def _add_track(self) -> None: - """Add a track to a section header making it a normal track row""" + """ + Add a track to a section header making it a normal track row. + """ - model_row_number = self.source_model_selected_row_number() - if model_row_number is None: - return - - with db.Session() as session: - dlg = TrackSelectDialog( - parent=self.musicmuster, - session=session, - new_row_number=model_row_number, - base_model=self.get_base_model(), - add_to_header=True, - ) - dlg.exec() - session.commit() + dlg = TrackInsertDialog( + parent=self.musicmuster, + playlist_id=self.playlist_id, + add_to_header=True, + ) + dlg.exec() def _build_context_menu(self, item: QTableWidgetItem) -> None: """Used to process context (right-click) menu, which is defined here""" @@ -527,12 +533,14 @@ class PlaylistTab(QTableView): header_row = self.get_base_model().is_header_row(model_row_number) track_row = not header_row - if track_sequence.current: - this_is_current_row = model_row_number == track_sequence.current.row_number + if self.track_sequence.current: + this_is_current_row = ( + model_row_number == self.track_sequence.current.row_number + ) else: this_is_current_row = False - if track_sequence.next: - this_is_next_row = model_row_number == track_sequence.next.row_number + if self.track_sequence.next: + this_is_next_row = model_row_number == self.track_sequence.next.row_number else: this_is_next_row = False track_path = base_model.get_row_info(model_row_number).path @@ -560,7 +568,7 @@ class PlaylistTab(QTableView): "Rescan track", lambda: self._rescan(model_row_number) ) self._add_context_menu("Mark for moving", lambda: self._mark_for_moving()) - if self.musicmuster.move_source_rows: + if self.musicmuster.move_source: self._add_context_menu( "Move selected rows here", lambda: self._move_selected_rows() ) @@ -668,8 +676,6 @@ class PlaylistTab(QTableView): Called when column width changes. Save new width to database. """ - log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}") - header = self.horizontalHeader() if not header: return @@ -677,11 +683,10 @@ class PlaylistTab(QTableView): # Resize rows if necessary self.resizeRowsToContents() - with db.Session() as session: - attr_name = f"playlist_col_{column_number}_width" - record = Settings.get_setting(session, attr_name) - record.f_int = self.columnWidth(column_number) - session.commit() + # Save settings + ds.setting_set( + f"playlist_col_{column_number}_width", self.columnWidth(column_number) + ) def _context_menu(self, pos): """Display right-click menu""" @@ -714,12 +719,19 @@ class PlaylistTab(QTableView): cb.clear(mode=cb.Mode.Clipboard) cb.setText(track_path, mode=cb.Mode.Clipboard) - def current_track_started(self) -> None: + # @log_call + def track_started_handler(self) -> None: """ Called when track starts playing """ - self.get_base_model().current_track_started() + if self.track_sequence.current is None: + return + + if self.track_sequence.current.playlist_id != self.playlist_id: + # Not for us + return + # Scroll to current section if hide mode is by section if ( self.musicmuster.hide_played_tracks @@ -749,8 +761,8 @@ class PlaylistTab(QTableView): # Don't delete current or next tracks selected_row_numbers = self.selected_model_row_numbers() for ts in [ - track_sequence.next, - track_sequence.current, + self.track_sequence.next, + self.track_sequence.current, ]: if ts: if ( @@ -801,6 +813,7 @@ class PlaylistTab(QTableView): else: return TrackInfo(track_id, selected_row) + # @log_call def get_selected_row(self) -> Optional[int]: """ Return selected row number. If no rows or multiple rows selected, return None @@ -812,6 +825,7 @@ class PlaylistTab(QTableView): else: return None + # @log_call def get_selected_rows(self) -> list[int]: """Return a list of model-selected row numbers sorted by row""" @@ -822,8 +836,11 @@ class PlaylistTab(QTableView): if not selected_indexes: return [] - return sorted(list(set([self.model().mapToSource(a).row() for a in selected_indexes]))) + return sorted( + list(set([self.model().mapToSource(a).row() for a in selected_indexes])) + ) + # @log_call def get_top_visible_row(self) -> int: """ Get the viewport of the table view @@ -942,13 +959,11 @@ class PlaylistTab(QTableView): self.get_base_model().rescan_track(row_number) self.clear_selection() - def resize_rows(self, playlist_id: Optional[int] = None) -> None: + def resize_rows_handler(self, playlist_id: Optional[int] = None) -> None: """ If playlist_id is us, resize rows """ - log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}") - if playlist_id and playlist_id != self.playlist_id: return @@ -995,6 +1010,7 @@ class PlaylistTab(QTableView): # Reset selection mode self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + # @log_call def source_model_selected_row_number(self) -> Optional[int]: """ Return the model row number corresponding to the selected row or None @@ -1005,6 +1021,7 @@ class PlaylistTab(QTableView): return None return self.model().mapToSource(selected_index).row() + # @log_call def selected_model_row_numbers(self) -> list[int]: """ Return a list of model row numbers corresponding to the selected rows or @@ -1047,21 +1064,18 @@ class PlaylistTab(QTableView): def _set_column_widths(self) -> None: """Column widths from settings""" - log.debug("_set_column_widths()") - header = self.horizontalHeader() if not header: return # Last column is set to stretch so ignore it here - with db.Session() as session: - for column_number in range(header.count() - 1): - attr_name = f"playlist_col_{column_number}_width" - record = Settings.get_setting(session, attr_name) - if record.f_int is not None: - self.setColumnWidth(column_number, record.f_int) - else: - self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH) + for column_number in range(header.count() - 1): + attr_name = f"playlist_col_{column_number}_width" + value = ds.setting_get(attr_name) + if value is not None: + self.setColumnWidth(column_number, value) + else: + self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH) def set_row_as_next_track(self) -> None: """ @@ -1072,10 +1086,10 @@ class PlaylistTab(QTableView): log.debug(f"set_row_as_next_track() {model_row_number=}") if model_row_number is None: return - self.get_base_model().set_next_row(model_row_number) + self.get_base_model().set_next_row_handler(model_row_number) self.clearSelection() - def _span_cells( + def _span_cells_handler( self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int ) -> None: """ @@ -1111,16 +1125,18 @@ class PlaylistTab(QTableView): """ # Update musicmuster - self.musicmuster.current.playlist_id = self.playlist_id - self.musicmuster.current.selected_rows = self.get_selected_rows() - self.musicmuster.current.base_model = self.get_base_model() - self.musicmuster.current.proxy_model = self.model() + self.musicmuster.update_current( + base_model=self.get_base_model(), + proxy_model=self.model(), + playlist_id=self.playlist_id, + selected_row_numbers=self.get_selected_rows(), + ) - self.resize_rows() + self.resize_rows_handler() def _unmark_as_next(self) -> None: """Rescan track""" - track_sequence.set_next(None) + self.track_sequence.set_next(None) self.clear_selection() - self.signals.next_track_changed_signal.emit() + self.signals.signal_set_next_track.emit(None) diff --git a/app/querylistmodel.py b/app/querylistmodel.py index 954074e..354c428 100644 --- a/app/querylistmodel.py +++ b/app/querylistmodel.py @@ -21,7 +21,6 @@ from PyQt6.QtGui import ( ) # Third party imports -from sqlalchemy.orm.session import Session # import snoop # type: ignore @@ -38,9 +37,9 @@ from helpers import ( ms_to_mmss, show_warning, ) -from log import log -from models import db, Playdates, Tracks -from music_manager import RowAndTrack +from log import log, log_call +from playlistrow import PlaylistRow +import ds @dataclass @@ -64,7 +63,7 @@ class QuerylistModel(QAbstractTableModel): """ - def __init__(self, session: Session, filter: Filter) -> None: + def __init__(self, filter: Filter) -> None: """ Load query """ @@ -72,7 +71,6 @@ class QuerylistModel(QAbstractTableModel): log.debug(f"QuerylistModel.__init__({filter=})") super().__init__() - self.session = session self.filter = filter self.querylist_rows: dict[int, QueryRow] = {} @@ -136,7 +134,7 @@ class QuerylistModel(QAbstractTableModel): row = index.row() column = index.column() - # rat for playlist row data as it's used a lot + # plr for playlist row data as it's used a lot qrow = self.querylist_rows[row] # Dispatch to role-specific functions @@ -230,21 +228,16 @@ class QuerylistModel(QAbstractTableModel): row = 0 try: - results = Tracks.get_filtered_tracks(self.session, self.filter) + results = ds.tracks_filtered(self.filter) for result in results: - lastplayed = None - if hasattr(result, "playdates"): - pds = result.playdates - if pds: - lastplayed = max([a.lastplayed for a in pds]) queryrow = QueryRow( artist=result.artist, bitrate=result.bitrate or 0, duration=result.duration, - lastplayed=lastplayed, + lastplayed=result.lastplayed, path=result.path, title=result.title, - track_id=result.id, + track_id=result.track_id, ) self.querylist_rows[row] = queryrow @@ -268,23 +261,14 @@ class QuerylistModel(QAbstractTableModel): bottom_right = self.index(row, self.columnCount() - 1) self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole]) - def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str | QVariant: + def _tooltip_role(self, row: int, column: int, plr: PlaylistRow) -> str | QVariant: """ Return tooltip. Currently only used for last_played column. """ if column != QueryCol.LAST_PLAYED.value: return QVariant() - with db.Session() as session: - track_id = self.querylist_rows[row].track_id - if not track_id: - return QVariant() - playdates = Playdates.last_playdates(session, track_id) - return ( - "
".join( - [ - a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) - for a in reversed(playdates) - ] - ) - ) + track_id = self.querylist_rows[row].track_id + if not track_id: + return QVariant() + return ds.playdates_get_last(track_id) diff --git a/app/ui/dlg_TrackSelect.ui b/app/ui/dlg_TrackSelect.ui deleted file mode 100644 index 455975c..0000000 --- a/app/ui/dlg_TrackSelect.ui +++ /dev/null @@ -1,131 +0,0 @@ - - - Dialog - - - - 0 - 0 - 584 - 377 - - - - Dialog - - - - - - Title: - - - - - - - - - - - - - - - - 0 - 0 - - - - - 46 - 16777215 - - - - &Note: - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - txtNote - - - - - - - - - - - - - - - - - - - - - &Title - - - true - - - - - - - &Artist - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - &Add - - - true - - - - - - - A&dd and close - - - - - - - &Close - - - - - - - - - - diff --git a/app/ui/dlg_TrackSelect_ui.py b/app/ui/dlg_TrackSelect_ui.py deleted file mode 100644 index 4b7a62e..0000000 --- a/app/ui/dlg_TrackSelect_ui.py +++ /dev/null @@ -1,83 +0,0 @@ -# Form implementation generated from reading ui file 'dlg_TrackSelect.ui' -# -# Created by: PyQt6 UI code generator 6.5.3 -# -# WARNING: Any manual changes made to this file will be lost when pyuic6 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt6 import QtCore, QtGui, QtWidgets - - -class Ui_Dialog(object): - def setupUi(self, Dialog): - Dialog.setObjectName("Dialog") - Dialog.resize(584, 377) - self.gridLayout = QtWidgets.QGridLayout(Dialog) - self.gridLayout.setObjectName("gridLayout") - self.label = QtWidgets.QLabel(parent=Dialog) - self.label.setObjectName("label") - self.gridLayout.addWidget(self.label, 0, 0, 1, 1) - self.searchString = QtWidgets.QLineEdit(parent=Dialog) - self.searchString.setObjectName("searchString") - self.gridLayout.addWidget(self.searchString, 0, 1, 1, 1) - self.matchList = QtWidgets.QListWidget(parent=Dialog) - self.matchList.setObjectName("matchList") - self.gridLayout.addWidget(self.matchList, 1, 0, 1, 2) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.lblNote = QtWidgets.QLabel(parent=Dialog) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.lblNote.sizePolicy().hasHeightForWidth()) - self.lblNote.setSizePolicy(sizePolicy) - self.lblNote.setMaximumSize(QtCore.QSize(46, 16777215)) - self.lblNote.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft|QtCore.Qt.AlignmentFlag.AlignTop) - self.lblNote.setObjectName("lblNote") - self.horizontalLayout.addWidget(self.lblNote) - self.txtNote = QtWidgets.QLineEdit(parent=Dialog) - self.txtNote.setObjectName("txtNote") - self.horizontalLayout.addWidget(self.txtNote) - self.gridLayout.addLayout(self.horizontalLayout, 2, 0, 1, 2) - self.dbPath = QtWidgets.QLabel(parent=Dialog) - self.dbPath.setText("") - self.dbPath.setObjectName("dbPath") - self.gridLayout.addWidget(self.dbPath, 3, 0, 1, 2) - self.horizontalLayout_2 = QtWidgets.QHBoxLayout() - self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.radioTitle = QtWidgets.QRadioButton(parent=Dialog) - self.radioTitle.setChecked(True) - self.radioTitle.setObjectName("radioTitle") - self.horizontalLayout_2.addWidget(self.radioTitle) - self.radioArtist = QtWidgets.QRadioButton(parent=Dialog) - self.radioArtist.setObjectName("radioArtist") - self.horizontalLayout_2.addWidget(self.radioArtist) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_2.addItem(spacerItem) - self.btnAdd = QtWidgets.QPushButton(parent=Dialog) - self.btnAdd.setDefault(True) - self.btnAdd.setObjectName("btnAdd") - self.horizontalLayout_2.addWidget(self.btnAdd) - self.btnAddClose = QtWidgets.QPushButton(parent=Dialog) - self.btnAddClose.setObjectName("btnAddClose") - self.horizontalLayout_2.addWidget(self.btnAddClose) - self.btnClose = QtWidgets.QPushButton(parent=Dialog) - self.btnClose.setObjectName("btnClose") - self.horizontalLayout_2.addWidget(self.btnClose) - self.gridLayout.addLayout(self.horizontalLayout_2, 4, 0, 1, 2) - self.lblNote.setBuddy(self.txtNote) - - self.retranslateUi(Dialog) - QtCore.QMetaObject.connectSlotsByName(Dialog) - - def retranslateUi(self, Dialog): - _translate = QtCore.QCoreApplication.translate - Dialog.setWindowTitle(_translate("Dialog", "Dialog")) - self.label.setText(_translate("Dialog", "Title:")) - self.lblNote.setText(_translate("Dialog", "&Note:")) - self.radioTitle.setText(_translate("Dialog", "&Title")) - self.radioArtist.setText(_translate("Dialog", "&Artist")) - self.btnAdd.setText(_translate("Dialog", "&Add")) - self.btnAddClose.setText(_translate("Dialog", "A&dd and close")) - self.btnClose.setText(_translate("Dialog", "&Close")) diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui deleted file mode 100644 index 53b2b13..0000000 --- a/app/ui/main_window.ui +++ /dev/null @@ -1,1421 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1280 - 857 - - - - - 1280 - 0 - - - - Music Muster - - - - :/icons/musicmuster:/icons/musicmuster - - - - - - - - - - - - - - - 0 - 0 - - - - - 230 - 16777215 - - - - - Sans - 20 - - - - background-color: #f8d7da; -border: 1px solid rgb(85, 87, 83); - - - Last track: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 230 - 16777215 - - - - - Sans - 20 - - - - background-color: #d4edda; -border: 1px solid rgb(85, 87, 83); - - - Current track: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 230 - 16777215 - - - - - Sans - 20 - - - - background-color: #fff3cd; -border: 1px solid rgb(85, 87, 83); - - - Next track: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - - Sans - 20 - - - - background-color: #f8d7da; -border: 1px solid rgb(85, 87, 83); - - - - - - false - - - - - - - - 0 - 0 - - - - - 20 - - - - background-color: #d4edda; -border: 1px solid rgb(85, 87, 83); -text-align: left; -padding-left: 8px; - - - - - - - true - - - - - - - - 0 - 0 - - - - - 20 - - - - background-color: #fff3cd; -border: 1px solid rgb(85, 87, 83); -text-align: left; -padding-left: 8px; - - - - - - true - - - - - - - - - - 0 - 131 - - - - - 230 - 131 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 208 - 0 - - - - - 35 - - - - 00:00:00 - - - Qt::AlignCenter - - - - - - - - FreeSans - 18 - 50 - false - - - - color: black; - - - 00:00 / 00:00 - - - Qt::AlignCenter - - - - - - - - - - - - - 0 - 16 - - - - false - - - background-color: rgb(154, 153, 150) - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - 0 - 16 - - - - false - - - background-color: rgb(154, 153, 150) - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - Qt::Vertical - - - - -1 - - - false - - - true - - - true - - - - - -1 - - - false - - - true - - - true - - - false - - - - - - - - - 16777215 - 16777215 - - - - background-color: rgb(192, 191, 188) - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 152 - 112 - - - - - 184 - 16777215 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 132 - 41 - - - - Preview - - - - :/icons/headphones:/icons/headphones - - - - 30 - 30 - - - - true - - - - - - - - 132 - 46 - - - - - 132 - 46 - - - - - - - - - 0 - 0 - 44 - 23 - - - - - 44 - 23 - - - - - 44 - 23 - - - - << - - - - - - 44 - 0 - 44 - 23 - - - - - 44 - 23 - - - - - 44 - 23 - - - - - - - - :/icons/record-button.png - :/icons/record-red-button.png:/icons/record-button.png - - - true - - - - - - 88 - 0 - 44 - 23 - - - - - 44 - 23 - - - - - 44 - 23 - - - - >> - - - - - - 0 - 23 - 44 - 23 - - - - - 44 - 23 - - - - - 44 - 23 - - - - < - - - - - false - - - - 44 - 23 - 44 - 23 - - - - - 44 - 23 - - - - - 44 - 23 - - - - - - - - :/icons/star.png - :/icons/star_empty.png - - - - - - - 88 - 23 - 44 - 23 - - - - - 44 - 23 - - - - - 44 - 23 - - - - > - - - - - - - - - - - - 152 - 112 - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Intro - - - Qt::AlignCenter - - - - - - - - FreeSans - 40 - 50 - false - - - - 0:0 - - - Qt::AlignCenter - - - - - - - - - - - 152 - 112 - - - - - 184 - 16777215 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 132 - 41 - - - - - 164 - 16777215 - - - - -3dB to talk - - - true - - - - - - - - 132 - 41 - - - - - 164 - 16777215 - - - - Hide played - - - true - - - - - - - - - - - 152 - 112 - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Fade - - - Qt::AlignCenter - - - - - - - - FreeSans - 40 - 50 - false - - - - 00:00 - - - Qt::AlignCenter - - - - - - - - - - - 152 - 112 - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Silent - - - Qt::AlignCenter - - - - - - - - FreeSans - 40 - 50 - false - - - - 00:00 - - - Qt::AlignCenter - - - - - - - - - - - 1 - 0 - - - - - 0 - 0 - - - - - - - - - 151 - 0 - - - - - 151 - 112 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 132 - 32 - - - - - 164 - 16777215 - - - - Fade - - - - :/icons/fade:/icons/fade - - - - 30 - 30 - - - - - - - - - 0 - 36 - - - - Stop - - - - :/icons/stopsign:/icons/stopsign - - - - - - - - - - - - - - - 0 - 0 - 1280 - 29 - - - - - &Playlist - - - - - - - - - - - - - - - - - - - - &File - - - - - - - - - - - - - - - - - - - - - - &Music - - - - - - - - - - - - - - - Help - - - - - - - - - - - - true - - - background-color: rgb(211, 215, 207); - - - - - - ../../../../../../.designer/backup/icon-play.png../../../../../../.designer/backup/icon-play.png - - - &Play next - - - Return - - - - - - :/icons/next:/icons/next - - - Skip to &next - - - Ctrl+Alt+Return - - - - - - ../../../../../../.designer/backup/icon_search_database.png../../../../../../.designer/backup/icon_search_database.png - - - Insert &track... - - - Ctrl+T - - - - - - ../../../../../../.designer/backup/icon_open_file.png../../../../../../.designer/backup/icon_open_file.png - - - Add &file - - - Ctrl+F - - - - - - ../../../../../../.designer/backup/icon-fade.png../../../../../../.designer/backup/icon-fade.png - - - F&ade - - - Ctrl+Z - - - - - - :/icons/stop:/icons/stop - - - S&top - - - Ctrl+Alt+S - - - - - Clear &selection - - - Esc - - - - - - :/icons/previous:/icons/previous - - - &Resume previous - - - - - E&xit - - - - - &Test - - - - - O&pen... - - - - - &New... - - - - - &Test function - - - - - &Skip to start of fade - - - - - Skip to &end of track - - - - - true - - - &Close - - - - - true - - - &Rename... - - - - - true - - - Dele&te... - - - - - Mo&ve selected tracks to... - - - - - E&xport... - - - - - Set &next - - - Ctrl+N - - - - - Select next track - - - J - - - - - Select previous track - - - K - - - - - Select played tracks - - - - - Move &unplayed tracks to... - - - - - Add note... - - - Ctrl+T - - - - - Enable controls - - - - - Import track... - - - Ctrl+Shift+I - - - - - Download CSV of played tracks... - - - - - Search... - - - / - - - - - Insert &section header... - - - Ctrl+H - - - - - &Remove track - - - - - Find next - - - N - - - - - Find previous - - - P - - - - - &About - - - - - Save as template... - - - - - Manage templates... - - - - - Debug - - - - - Edit cart &1... - - - - - Mark for moving - - - Ctrl+C - - - - - Paste - - - Ctrl+V - - - - - Resume - - - Ctrl+R - - - - - Search title in Wikipedia - - - Ctrl+W - - - - - Search title in Songfacts - - - Ctrl+S - - - - - Select duplicate rows... - - - - - Import files... - - - - - Open &querylist... - - - - - Manage querylists... - - - - - - InfoTabs - QTabWidget -
infotabs
- 1 -
- - PlotWidget - QWidget -
pyqtgraph
- 1 -
-
- - - - - - actionE_xit - triggered() - MainWindow - close() - - - -1 - -1 - - - 383 - 299 - - - - -
diff --git a/app/utilities.py b/app/utilities.py index 8c42a8a..820cd3d 100755 --- a/app/utilities.py +++ b/app/utilities.py @@ -5,7 +5,6 @@ import os # PyQt imports # Third party imports -from sqlalchemy.orm.session import Session # App imports from config import Config @@ -13,10 +12,10 @@ from helpers import ( get_tags, ) from log import log -from models import Tracks +import ds -def check_db(session: Session) -> None: +def check_db() -> None: """ Database consistency check. @@ -27,7 +26,7 @@ def check_db(session: Session) -> None: Check all paths in database exist """ - db_paths = set([a.path for a in Tracks.get_all(session)]) + db_paths = set([a.path for a in ds.tracks_all()]) os_paths_list = [] for root, _dirs, files in os.walk(Config.ROOT): @@ -52,7 +51,7 @@ def check_db(session: Session) -> None: missing_file_count += 1 - track = Tracks.get_by_path(session, path) + track = ds.track_by_path(path) if not track: # This shouldn't happen as we're looking for paths in # database that aren't in filesystem, but just in case... @@ -74,7 +73,7 @@ def check_db(session: Session) -> None: for t in paths_not_found: print( f""" - Track ID: {t.id} + Track ID: {t.track_id} Path: {t.path} Title: {t.title} Artist: {t.artist} @@ -84,14 +83,14 @@ def check_db(session: Session) -> None: print("There were more paths than listed that were not found") -def update_bitrates(session: Session) -> None: +def update_bitrates() -> None: """ Update bitrates on all tracks in database """ - for track in Tracks.get_all(session): + for track in ds.tracks_all(): try: t = get_tags(track.path) - track.bitrate = t.bitrate + ds.track_update(track.track_id, t._asdict()) except FileNotFoundError: continue diff --git a/app/vlcmanager.py b/app/vlcmanager.py deleted file mode 100644 index 8ae882d..0000000 --- a/app/vlcmanager.py +++ /dev/null @@ -1,29 +0,0 @@ -# Standard library imports - -# PyQt imports - -# Third party imports -import vlc # type: ignore - -# App imports - - -class VLCManager: - """ - Singleton class to ensure we only ever have one vlc Instance - """ - - __instance = None - - def __init__(self) -> None: - if VLCManager.__instance is None: - self.vlc_instance = vlc.Instance() - VLCManager.__instance = self - else: - raise Exception("Attempted to create a second VLCManager instance") - - @staticmethod - def get_instance() -> vlc.Instance: - if VLCManager.__instance is None: - VLCManager() - return VLCManager.__instance diff --git a/migrations/env.py b/migrations/env.py index 027fd36..c6420ae 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -2,25 +2,26 @@ from importlib import import_module from alembic import context from alchemical.alembic.env import run_migrations -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. +# Load Alembic configuration config = context.config -# import the application's Alchemical instance try: - import_mod, db_name = config.get_main_option('alchemical_db', '').split( - ':') + # Import the Alchemical database instance as specified in alembic.ini + import_mod, db_name = config.get_main_option('alchemical_db', '').split(':') db = getattr(import_module(import_mod), db_name) -except (ModuleNotFoundError, AttributeError): - raise ValueError( - 'Could not import the Alchemical database instance. ' - 'Ensure that the alchemical_db setting in alembic.ini is correct.' - ) + print(f"Successfully loaded Alchemical database instance: {db}") -# run the migration engine -# The dictionary provided as second argument includes options to pass to the -# Alembic context. For details on what other options are available, see -# https://alembic.sqlalchemy.org/en/latest/autogenerate.html + # Use the metadata associated with the Alchemical instance + metadata = db.Model.metadata + print(f"Metadata tables detected: {metadata.tables.keys()}") # Debug output +except (ModuleNotFoundError, AttributeError) as e: + raise ValueError( + 'Could not import the Alchemical database instance or access metadata. ' + 'Ensure that the alchemical_db setting in alembic.ini is correct and ' + 'that the Alchemical instance is correctly configured.' + ) from e + +# Run migrations with metadata run_migrations(db, { 'render_as_batch': True, 'compare_type': True, diff --git a/migrations/versions/6d36cde8dea0_notes_substrings_indexing_playlist_.py b/migrations/versions/6d36cde8dea0_notes_substrings_indexing_playlist_.py new file mode 100644 index 0000000..08083cb --- /dev/null +++ b/migrations/versions/6d36cde8dea0_notes_substrings_indexing_playlist_.py @@ -0,0 +1,68 @@ +"""notes substrings, indexing, playlist faviourites, bitrate not null + +Revision ID: 6d36cde8dea0 +Revises: 4fc2a9a82ab0 +Create Date: 2025-04-22 17:03:00.497945 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '6d36cde8dea0' +down_revision = '4fc2a9a82ab0' +branch_labels = None +depends_on = None + + +def upgrade(engine_name: str) -> None: + globals()["upgrade_%s" % engine_name]() + + +def downgrade(engine_name: str) -> None: + globals()["downgrade_%s" % engine_name]() + + + + + +def upgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('notecolours', schema=None) as batch_op: + batch_op.add_column(sa.Column('strip_substring', sa.Boolean(), nullable=False)) + batch_op.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=True) + + with op.batch_alter_table('playlist_rows', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_playlist_rows_playlist_id'), ['playlist_id'], unique=False) + + with op.batch_alter_table('playlists', schema=None) as batch_op: + batch_op.add_column(sa.Column('favourite', sa.Boolean(), nullable=False)) + + with op.batch_alter_table('tracks', schema=None) as batch_op: + batch_op.alter_column('bitrate', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + + # ### end Alembic commands ### + + +def downgrade_() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tracks', schema=None) as batch_op: + batch_op.alter_column('bitrate', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + + with op.batch_alter_table('playlists', schema=None) as batch_op: + batch_op.drop_column('favourite') + + with op.batch_alter_table('playlist_rows', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_playlist_rows_playlist_id')) + + with op.batch_alter_table('notecolours', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_notecolours_substring')) + batch_op.drop_column('strip_substring') + + # ### end Alembic commands ### + diff --git a/migrations/versions/8e06d465923a_have_id_field_reflect_table_name.py b/migrations/versions/8e06d465923a_have_id_field_reflect_table_name.py new file mode 100644 index 0000000..ade8000 --- /dev/null +++ b/migrations/versions/8e06d465923a_have_id_field_reflect_table_name.py @@ -0,0 +1,86 @@ +"""Have id field reflect table name + +Revision ID: 8e06d465923a +Revises: 6d36cde8dea0 +Create Date: 2025-04-22 13:23:18.813024 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from dataclasses import dataclass + + +@dataclass +class TableInfo: + table: str + old: str + new: str + + +data = [ + TableInfo("notecolours", "id", "notecolour_id"), + TableInfo("playdates", "id", "playdate_id"), + TableInfo("playlists", "id", "playlist_id"), + TableInfo("playlist_rows", "id", "playlistrow_id"), + TableInfo("queries", "id", "query_id"), + TableInfo("settings", "id", "setting_id"), + TableInfo("tracks", "id", "track_id"), +] + + +# revision identifiers, used by Alembic. +revision = '8e06d465923a' +down_revision = '6d36cde8dea0' +branch_labels = None +depends_on = None + + +def upgrade(engine_name: str) -> None: + globals()["upgrade_%s" % engine_name]() + + +def downgrade(engine_name: str) -> None: + globals()["downgrade_%s" % engine_name]() + + +def upgrade_() -> None: + # Drop foreign key constraints + op.drop_constraint('fk_playdates_track_id_tracks', 'playdates', type_='foreignkey') + op.drop_constraint('fk_playlist_rows_track_id_tracks', 'playlist_rows', type_='foreignkey') + + for record in data: + op.alter_column( + record.table, + record.old, + new_column_name=record.new, + existing_type=sa.Integer(), # Specify the existing column type + existing_nullable=False # If the column is NOT NULL, specify that too + ) + + + # Recreate the foreign key constraints + op.create_foreign_key('fk_playdates_track_id_tracks', 'playdates', 'tracks', ['track_id'], ['track_id']) + op.create_foreign_key('fk_playlist_rows_track_id_tracks', 'playlist_rows', 'tracks', ['track_id'], ['track_id']) + # ### end Alembic commands ### + + +def downgrade_() -> None: + # Drop foreign key constraints + op.drop_constraint('fk_playdates_track_id_tracks', 'playdates', type_='foreignkey') + op.drop_constraint('fk_playlist_rows_track_id_tracks', 'playlist_rows', type_='foreignkey') + + for record in data: + op.alter_column( + record.table, + record.new, + new_column_name=record.old, + existing_type=sa.Integer(), # Specify the existing column type + existing_nullable=False # If the column is NOT NULL, specify that too + ) + + # Recreate the foreign key constraints + op.create_foreign_key('fk_playdates_track_id_tracks', 'playdates', 'tracks', ['track_id'], ['track_id']) + op.create_foreign_key('fk_playlist_rows_track_id_tracks', 'playlist_rows', 'tracks', ['track_id'], ['track_id']) + + # ### end Alembic commands ### diff --git a/tests/test_models.py b/tests/X_test_models.py similarity index 100% rename from tests/test_models.py rename to tests/X_test_models.py diff --git a/tests/template_test_harness.py b/tests/template_test_harness.py new file mode 100644 index 0000000..b01b20b --- /dev/null +++ b/tests/template_test_harness.py @@ -0,0 +1,40 @@ +# Standard library imports +import unittest + +# PyQt imports + +# Third party imports + +# App imports +from app.models import ( + db, +) + + +class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Runs once before any test in this class""" + + pass + + @classmethod + def tearDownClass(cls): + """Runs once after all tests""" + + pass + + def setUp(self): + """Runs before each test""" + + db.create_all() + + def tearDown(self): + """Runs after each test""" + + db.drop_all() + + def test_xxx(self): + """Comment""" + + pass diff --git a/tests/test_ds.py b/tests/test_ds.py new file mode 100644 index 0000000..371828a --- /dev/null +++ b/tests/test_ds.py @@ -0,0 +1,297 @@ +# Standard library imports +import unittest + +# PyQt imports + +# Third party imports + +# App imports +from app import playlistmodel +from app import ds +from classes import PlaylistDTO +from helpers import get_all_track_metadata +from playlistmodel import PlaylistModel + + +class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Runs once before any test in this class""" + + cls.isa_path = "testdata/isa.mp3" + cls.isa_title = "I'm So Afraid" + cls.isa_artist = "Fleetwood Mac" + cls.mom_path = "testdata/mom.mp3" + cls.mom_title = "Man of Mystery" + cls.mom_artist = "The Shadows" + + @classmethod + def tearDownClass(cls): + """Runs once after all tests""" + + pass + + def setUp(self): + """Runs before each test""" + + ds.db.create_all() + + def playlist_create_and_model( + self, playlist_name: str + ) -> (PlaylistDTO, PlaylistModel): + # Create a playlist and model + playlist = ds.playlist_create(name=playlist_name, template_id=0) + assert playlist + model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False) + assert model + + return (playlist, model) + + def playlist_create_model_tracks(self, playlist_name: str): + (playlist, model) = self.playlist_create_and_model(playlist_name) + # Create tracks + metadata1 = get_all_track_metadata(self.isa_path) + self.track1 = ds.track_create(metadata1) + + metadata2 = get_all_track_metadata(self.mom_path) + self.track2 = ds.track_create(metadata2) + + # Add tracks and header to playlist + self.row0 = ds.playlist_insert_row( + playlist.playlist_id, + row_number=0, + track_id=self.track1.track_id, + note="track 1", + ) + self.row1 = ds.playlist_insert_row( + playlist.playlist_id, + row_number=1, + track_id=0, + note="Header row", + ) + self.row2 = ds.playlist_insert_row( + playlist.playlist_id, + row_number=2, + track_id=self.track2.track_id, + note="track 2", + ) + + def create_rows( + self, playlist_name: str, number_of_rows: int + ) -> (PlaylistDTO, PlaylistModel): + (playlist, model) = self.playlist_create_and_model(playlist_name) + for row_number in range(number_of_rows): + ds.playlist_insert_row( + playlist.playlist_id, row_number, None, str(row_number) + ) + + return (playlist, model) + + def tearDown(self): + """Runs after each test""" + + ds.db.drop_all() + + def test_add_track_to_header(self): + """Add a track to a header row""" + + self.playlist_create_model_tracks("my playlist") + ds.track_add_to_header(self.row1.playlistrow_id, self.track2.track_id) + result = ds.playlistrow_by_id(self.row1.playlistrow_id) + assert result.track.track_id == self.track2.track_id + + def test_track_create(self): + metadata = get_all_track_metadata(self.isa_path) + ds.track_create(metadata) + results = ds.tracks_all() + assert len(results) == 1 + assert results[0].path == self.isa_path + + def test_get_track_by_id(self): + metadata = get_all_track_metadata(self.isa_path) + dto = ds.track_create(metadata) + result = ds.track_by_id(dto.track_id) + assert result.path == self.isa_path + + def test_get_track_by_artist(self): + metadata = get_all_track_metadata(self.isa_path) + _ = ds.track_create(metadata) + metadata = get_all_track_metadata(self.mom_path) + _ = ds.track_create(metadata) + result_isa = ds.tracks_by_artist(self.isa_artist) + assert len(result_isa) == 1 + assert result_isa[0].artist == self.isa_artist + result_mom = ds.tracks_by_artist(self.mom_artist) + assert len(result_mom) == 1 + assert result_mom[0].artist == self.mom_artist + + def test_get_track_by_title(self): + metadata_isa = get_all_track_metadata(self.isa_path) + _ = ds.track_create(metadata_isa) + metadata_mom = get_all_track_metadata(self.mom_path) + _ = ds.track_create(metadata_mom) + result_isa = ds.tracks_by_title(self.isa_title) + assert len(result_isa) == 1 + assert result_isa[0].title == self.isa_title + result_mom = ds.tracks_by_title(self.mom_title) + assert len(result_mom) == 1 + assert result_mom[0].title == self.mom_title + + def test_tracks_get_all_tracks(self): + self.playlist_create_model_tracks(playlist_name="test_track_get_all_tracks") + all_tracks = ds.tracks_all() + assert len(all_tracks) == 2 + + def test_tracks_by_path(self): + metadata_isa = get_all_track_metadata(self.isa_path) + _ = ds.track_create(metadata_isa) + metadata_mom = get_all_track_metadata(self.mom_path) + _ = ds.track_create(metadata_mom) + result_isa = ds.track_by_path(self.isa_path) + assert result_isa.title == self.isa_title + result_mom = ds.track_by_path(self.mom_path) + assert result_mom.title == self.mom_title + + def test_move_rows_test1(self): + # move row 3 to row 5 + + number_of_rows = 10 + (playlist, model) = self.create_rows("test_move_rows_test1", number_of_rows) + + ds.playlist_move_rows([3], playlist.playlist_id, 5) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [0, 1, 2, 4, 3, 5, 6, 7, 8, 9] + + def test_move_rows_test2(self): + # move row 4 to row 3 + + number_of_rows = 10 + (playlist, model) = self.create_rows("test_move_rows_test2", number_of_rows) + + ds.playlist_move_rows([4], playlist.playlist_id, 3) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [0, 1, 2, 4, 3, 5, 6, 7, 8, 9] + + def test_move_rows_test3(self): + # move row 4 to row 2 + + number_of_rows = 10 + (playlist, model) = self.create_rows("test_move_rows_test3", number_of_rows) + + ds.playlist_move_rows([4], playlist.playlist_id, 2) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [0, 1, 4, 2, 3, 5, 6, 7, 8, 9] + + def test_move_rows_test4(self): + # move rows [1, 4, 5, 10] → 8 + + number_of_rows = 11 + (playlist, model) = self.create_rows("test_move_rows_test4", number_of_rows) + + ds.playlist_move_rows([1, 4, 5, 10], playlist.playlist_id, 8) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9] + + def test_move_rows_test5(self): + # move rows [3, 6] → 5 + + number_of_rows = 11 + (playlist, model) = self.create_rows("test_move_rows_test5", number_of_rows) + + ds.playlist_move_rows([3, 6], playlist.playlist_id, 5) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10] + + def test_move_rows_test6(self): + # move rows [3, 5, 6] → 8 + + number_of_rows = 11 + (playlist, model) = self.create_rows("test_move_rows_test6", number_of_rows) + + ds.playlist_move_rows([3, 5, 6], playlist.playlist_id, 8) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10] + + def test_move_rows_test7(self): + # move rows [7, 8, 10] → 5 + + number_of_rows = 11 + (playlist, model) = self.create_rows("test_move_rows_test7", number_of_rows) + + ds.playlist_move_rows([7, 8, 10], playlist.playlist_id, 5) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9] + + def test_move_rows_test8(self): + # move rows [1, 2, 3] → 0 + # Replicate issue 244 + + number_of_rows = 11 + (playlist, model) = self.create_rows("test_move_rows_test8", number_of_rows) + + ds.playlist_move_rows([1, 2, 3], playlist.playlist_id, 0) + + # Check we have all rows and plr_rownums are correct + new_order = [] + for row in ds.playlistrows_by_playlist(playlist.playlist_id): + new_order.append(int(row.note)) + assert new_order == [1, 2, 3, 0, 4, 5, 6, 7, 8, 9, 10] + + def test_move_rows_to_playlist(self): + number_of_rows = 11 + rows_to_move = [2, 4, 6] + to_row = 5 + + (playlist_src, model_src) = self.create_rows("src playlist", number_of_rows) + (playlist_dst, model_dst) = self.create_rows("dst playlist", number_of_rows) + + ds.playlist_move_rows( + rows_to_move, playlist_src.playlist_id, to_row, playlist_dst.playlist_id + ) + + # Check we have all rows and plr_rownums are correct + new_order_src = [] + for row in ds.playlistrows_by_playlist(playlist_src.playlist_id): + new_order_src.append(int(row.note)) + assert new_order_src == [0, 1, 3, 5, 7, 8, 9, 10] + new_order_dst = [] + for row in ds.playlistrows_by_playlist(playlist_dst.playlist_id): + new_order_dst.append(int(row.note)) + assert new_order_dst == [0, 1, 2, 3, 4, 2, 4, 6, 5, 6, 7, 8, 9, 10] + + def test_remove_rows(self): + pass + + def test_get_playlist_by_id(self): + pass + + def test_settings(self): + pass diff --git a/tests/test_file_importer.py b/tests/test_file_importer.py index f49e557..58e08c3 100644 --- a/tests/test_file_importer.py +++ b/tests/test_file_importer.py @@ -20,12 +20,7 @@ import pytest from pytestqt.plugin import QtBot # type: ignore # App imports -from app import musicmuster -from app.models import ( - db, - Playlists, - Tracks, -) +from app import ds, musicmuster from config import Config from file_importer import FileImporter @@ -50,15 +45,14 @@ class MyTestCase(unittest.TestCase): def setUpClass(cls): """Runs once before any test in this class""" - db.create_all() + ds.db.create_all() cls.widget = musicmuster.Window() # Create a playlist for all tests playlist_name = "file importer playlist" - with db.Session() as session: - playlist = Playlists(session=session, name=playlist_name, template_id=0) - cls.widget._open_playlist(playlist) + playlist = ds.playlist_create(name=playlist_name, template_id=0) + cls.widget._open_playlist(playlist) # Create our musicstore cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp") @@ -70,7 +64,7 @@ class MyTestCase(unittest.TestCase): def tearDownClass(cls): """Runs once after all tests""" - db.drop_all() + ds.db.drop_all() shutil.rmtree(cls.musicstore) shutil.rmtree(cls.import_source) @@ -84,7 +78,8 @@ class MyTestCase(unittest.TestCase): """Runs after each test""" self.widget.close() # Close UI to prevent side effects - def wait_for_workers(self, timeout: int = 10000): + # def wait_for_workers(self, timeout: int = 10000): + def wait_for_workers(self, timeout: int = 1000000): """ Let import threads workers run to completion """ @@ -176,18 +171,15 @@ class MyTestCase(unittest.TestCase): self.wait_for_workers() # Check track was imported - with db.Session() as session: - tracks = Tracks.get_all(session) - assert len(tracks) == 1 - track = tracks[0] - assert track.title == "I'm So Afraid" - assert track.artist == "Fleetwood Mac" - track_file = os.path.join( - self.musicstore, os.path.basename(test_track_path) - ) - assert track.path == track_file - assert os.path.exists(track_file) - assert os.listdir(self.import_source) == [] + tracks = ds.tracks_all() + assert len(tracks) == 1 + track = tracks[0] + assert track.title == "I'm So Afraid" + assert track.artist == "Fleetwood Mac" + track_file = os.path.join(self.musicstore, os.path.basename(test_track_path)) + assert track.path == track_file + assert os.path.exists(track_file) + assert os.listdir(self.import_source) == [] def test_004_import_second_file(self): """Import a second file""" @@ -222,18 +214,15 @@ class MyTestCase(unittest.TestCase): self.wait_for_workers() # Check track was imported - with db.Session() as session: - tracks = Tracks.get_all(session) - assert len(tracks) == 2 - track = tracks[1] - assert track.title == "The Lovecats" - assert track.artist == "The Cure" - track_file = os.path.join( - self.musicstore, os.path.basename(test_track_path) - ) - assert track.path == track_file - assert os.path.exists(track_file) - assert os.listdir(self.import_source) == [] + tracks = ds.tracks_all() + assert len(tracks) == 2 + track = tracks[1] + assert track.title == "The Lovecats" + assert track.artist == "The Cure" + track_file = os.path.join(self.musicstore, os.path.basename(test_track_path)) + assert track.path == track_file + assert os.path.exists(track_file) + assert os.listdir(self.import_source) == [] def test_005_replace_file(self): """Import the same file again and update existing track""" @@ -275,19 +264,16 @@ class MyTestCase(unittest.TestCase): self.wait_for_workers() # Check track was imported - with db.Session() as session: - tracks = Tracks.get_all(session) - assert len(tracks) == 2 - track = tracks[1] - assert track.title == "The Lovecats" - assert track.artist == "The Cure" - assert track.id == 2 - track_file = os.path.join( - self.musicstore, os.path.basename(test_track_path) - ) - assert track.path == track_file - assert os.path.exists(track_file) - assert os.listdir(self.import_source) == [] + tracks = ds.tracks_all() + assert len(tracks) == 2 + track = tracks[1] + assert track.title == "The Lovecats" + assert track.artist == "The Cure" + assert track.track_id == 2 + track_file = os.path.join(self.musicstore, os.path.basename(test_track_path)) + assert track.path == track_file + assert os.path.exists(track_file) + assert os.listdir(self.import_source) == [] def test_006_import_file_no_tags(self) -> None: """Try to import untagged file""" @@ -405,25 +391,22 @@ class MyTestCase(unittest.TestCase): assert result[0] == new_destination # Validate return value # Check track was imported - with db.Session() as session: - tracks = Tracks.get_all(session) - assert len(tracks) == 3 - track = tracks[2] - assert track.title == "The Lovecats" - assert track.artist == "The Cure" - assert track.id == 3 - assert track.path == new_destination - assert os.path.exists(new_destination) - assert os.listdir(self.import_source) == [] + tracks = ds.tracks_all() + track = tracks[2] + assert track.title == "The Lovecats" + assert track.artist == "The Cure" + assert track.track_id == 3 + assert track.path == new_destination + assert os.path.exists(new_destination) + assert os.listdir(self.import_source) == [] - # Remove file so as not to interfere with later tests - session.delete(track) - tracks = Tracks.get_all(session) - assert len(tracks) == 2 - session.commit() + # Remove file so as not to interfere with later tests + ds.track_delete(track.track_id) + tracks = ds.tracks_all() + assert len(tracks) == 2 - os.unlink(new_destination) - assert not os.path.exists(new_destination) + os.unlink(new_destination) + assert not os.path.exists(new_destination) def test_009_import_similar_file(self) -> None: """Import file with similar, but different, title""" @@ -474,16 +457,13 @@ class MyTestCase(unittest.TestCase): self.wait_for_workers() # Check track was imported - with db.Session() as session: - tracks = Tracks.get_all(session) - assert len(tracks) == 2 - track = tracks[1] - assert track.title == "The Lovecats xyz" - assert track.artist == "The Cure" - assert track.id == 2 - track_file = os.path.join( - self.musicstore, os.path.basename(test_track_path) - ) - assert track.path == track_file - assert os.path.exists(track_file) - assert os.listdir(self.import_source) == [] + tracks = ds.tracks_all() + assert len(tracks) == 2 + track = tracks[1] + assert track.title == "The Lovecats xyz" + assert track.artist == "The Cure" + assert track.track_id == 2 + track_file = os.path.join(self.musicstore, os.path.basename(test_track_path)) + assert track.path == track_file + assert os.path.exists(track_file) + assert os.listdir(self.import_source) == [] diff --git a/tests/test_helpers.py b/tests/test_helpers.py index e7cd365..0a2418a 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -64,9 +64,9 @@ class TestMMHelpers(unittest.TestCase): today_at_11 = dt.datetime.now().replace(hour=11, minute=0) assert get_relative_date(today_at_10, today_at_11) == "Today 10:00" eight_days_ago = today_at_10 - dt.timedelta(days=8) - assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day" + assert get_relative_date(eight_days_ago, today_at_11) == "1w, 1d" sixteen_days_ago = today_at_10 - dt.timedelta(days=16) - assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days" + assert get_relative_date(sixteen_days_ago, today_at_11) == "2w, 2d" def test_leading_silence(self): test_track_path = "testdata/isa.mp3" diff --git a/tests/test_misc.py b/tests/test_misc.py index b85c685..e787669 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -7,15 +7,15 @@ import unittest import pytest # App imports -from app.models import db, Settings +import ds class TestMMMisc(unittest.TestCase): def setUp(self): - db.create_all() + ds.db.create_all() def tearDown(self): - db.drop_all() + ds.db.drop_all() def test_log_exception(self): """Test deliberate exception""" @@ -25,16 +25,11 @@ class TestMMMisc(unittest.TestCase): def test_create_settings(self): SETTING_NAME = "wombat" - NO_SUCH_SETTING = "abc" VALUE = 3 - with db.Session() as session: - setting = Settings(session, SETTING_NAME) - # test repr - _ = str(setting) - setting.f_int = VALUE - test = Settings.get_setting(session, SETTING_NAME) - assert test.name == SETTING_NAME - assert test.f_int == VALUE - test_new = Settings.get_setting(session, NO_SUCH_SETTING) - assert test_new.name == NO_SUCH_SETTING + test_non_existant = ds.setting_get(SETTING_NAME) + assert test_non_existant is None + + ds.setting_set(SETTING_NAME, VALUE) + test_ok = ds.setting_get(SETTING_NAME) + assert test_ok == VALUE diff --git a/tests/test_playlistmodel.py b/tests/test_playlistmodel.py index 3590b1a..fc0c16e 100644 --- a/tests/test_playlistmodel.py +++ b/tests/test_playlistmodel.py @@ -8,11 +8,10 @@ from PyQt6.QtCore import Qt, QModelIndex # App imports from app.helpers import get_all_track_metadata -from app import playlistmodel -from app.models import ( - db, - Playlists, - Tracks, +from app import ds, playlistmodel +from classes import ( + InsertTrack, + TrackAndPlaylist, ) @@ -30,24 +29,28 @@ class TestMMMiscTracks(unittest.TestCase): "testdata/wrb.flac", ] - db.create_all() + ds.db.create_all() # Create a playlist and model - with db.Session() as session: - self.playlist = Playlists(session, PLAYLIST_NAME, template_id=0) - self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) + self.playlist = ds.playlist_create(PLAYLIST_NAME, template_id=0) + self.model = playlistmodel.PlaylistModel( + self.playlist.playlist_id, is_template=False + ) - for row in range(len(self.test_tracks)): - track_path = self.test_tracks[row % len(self.test_tracks)] - track = Tracks(session, **get_all_track_metadata(track_path)) - self.model.insert_row( - proposed_row_number=row, track_id=track.id, note=f"{row=}" + for row in range(len(self.test_tracks)): + track_path = self.test_tracks[row % len(self.test_tracks)] + metadata = get_all_track_metadata(track_path) + track = ds.track_create(metadata) + self.model.insert_row_signal_handler( + InsertTrack( + playlist_id=self.playlist.playlist_id, + track_id=track.track_id, + note=f"{row=}", ) - - session.commit() + ) def tearDown(self): - db.drop_all() + ds.db.drop_all() def test_8_row_playlist(self): # Test auto-created playlist @@ -62,8 +65,17 @@ class TestMMMiscTracks(unittest.TestCase): START_ROW = 0 END_ROW = 2 - self.model.insert_row(proposed_row_number=START_ROW, note="start+") - self.model.insert_row(proposed_row_number=END_ROW, note="-") + # Fake selected row in model + self.model.selected_rows = [self.model.playlist_rows[START_ROW]] + self.model.insert_row_signal_handler( + InsertTrack( + playlist_id=self.playlist.playlist_id, track_id=None, note="start+" + ) + ) + self.model.selected_rows = [self.model.playlist_rows[END_ROW]] + self.model.insert_row_signal_handler( + InsertTrack(playlist_id=self.playlist.playlist_id, track_id=None, note="-+") + ) prd = self.model.playlist_rows[START_ROW] qv_value = self.model._display_role( @@ -85,35 +97,38 @@ class TestMMMiscNoPlaylist(unittest.TestCase): ] def setUp(self): - db.create_all() + ds.db.create_all() def tearDown(self): - db.drop_all() + ds.db.drop_all() def test_insert_track_new_playlist(self): # insert a track into a new playlist - with db.Session() as session: - playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0) - # Create a model - model = playlistmodel.PlaylistModel(playlist.id, is_template=False) - # test repr - _ = str(model) + playlist = ds.playlist_create(self.PLAYLIST_NAME, template_id=0) + # Create a model + model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False) + # test repr + _ = str(model) - track_path = self.test_tracks[0] - metadata = get_all_track_metadata(track_path) - track = Tracks(session, **metadata) - model.insert_row(proposed_row_number=0, track_id=track.id) - - prd = model.playlist_rows[model.rowCount() - 1] - # test repr - _ = str(prd) - - assert ( - model._edit_role( - model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd - ) - == metadata["title"] + track_path = self.test_tracks[0] + metadata = get_all_track_metadata(track_path) + track = ds.track_create(metadata) + model.insert_row_signal_handler( + InsertTrack( + playlist_id=playlist.playlist_id, + track_id=track.track_id, + note="", ) + ) + + prd = model.playlist_rows[model.rowCount() - 1] + # test repr + _ = str(prd) + + assert ( + model._edit_role(model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd) + == metadata["title"] + ) class TestMMMiscRowMove(unittest.TestCase): @@ -121,134 +136,23 @@ class TestMMMiscRowMove(unittest.TestCase): ROWS_TO_CREATE = 11 def setUp(self): - db.create_all() + ds.db.create_all() - with db.Session() as session: - self.playlist = Playlists(session, self.PLAYLIST_NAME, template_id=0) - self.model = playlistmodel.PlaylistModel(self.playlist.id, is_template=False) - for row in range(self.ROWS_TO_CREATE): - self.model.insert_row(proposed_row_number=row, note=str(row)) - - session.commit() + self.playlist = ds.playlist_create(self.PLAYLIST_NAME, template_id=0) + self.model = playlistmodel.PlaylistModel( + self.playlist.playlist_id, is_template=False + ) + for row in range(self.ROWS_TO_CREATE): + self.model.insert_row_signal_handler( + InsertTrack( + playlist_id=self.playlist.playlist_id, + track_id=None, + note=str(row), + ) + ) def tearDown(self): - db.drop_all() - - def test_move_rows_test2(self): - # move row 3 to row 5 - self.model.move_rows([3], 5) - # Check we have all rows and plr_rownums are correct - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - if row not in [3, 4, 5]: - assert self.model.playlist_rows[row].note == str(row) - elif row == 3: - assert self.model.playlist_rows[row].note == str(4) - elif row == 4: - assert self.model.playlist_rows[row].note == str(3) - elif row == 5: - assert self.model.playlist_rows[row].note == str(5) - - def test_move_rows_test3(self): - # move row 4 to row 3 - - self.model.move_rows([4], 3) - - # Check we have all rows and plr_rownums are correct - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - if row not in [3, 4]: - assert self.model.playlist_rows[row].note == str(row) - elif row == 3: - assert self.model.playlist_rows[row].note == str(4) - elif row == 4: - assert self.model.playlist_rows[row].note == str(3) - - def test_move_rows_test4(self): - # move row 4 to row 2 - - self.model.move_rows([4], 2) - - # Check we have all rows and plr_rownums are correct - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - if row not in [2, 3, 4]: - assert self.model.playlist_rows[row].note == str(row) - elif row == 2: - assert self.model.playlist_rows[row].note == str(4) - elif row == 3: - assert self.model.playlist_rows[row].note == str(2) - elif row == 4: - assert self.model.playlist_rows[row].note == str(3) - - def test_move_rows_test5(self): - # move rows [1, 4, 5, 10] → 8 - - self.model.move_rows([1, 4, 5, 10], 8) - - # Check we have all rows and plr_rownums are correct - new_order = [] - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - new_order.append(int(self.model.playlist_rows[row].note)) - assert new_order == [0, 2, 3, 6, 7, 1, 4, 5, 10, 8, 9] - - def test_move_rows_test6(self): - # move rows [3, 6] → 5 - - self.model.move_rows([3, 6], 5) - - # Check we have all rows and plr_rownums are correct - new_order = [] - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - new_order.append(int(self.model.playlist_rows[row].note)) - assert new_order == [0, 1, 2, 4, 3, 6, 5, 7, 8, 9, 10] - - def test_move_rows_test7(self): - # move rows [3, 5, 6] → 8 - - self.model.move_rows([3, 5, 6], 8) - - # Check we have all rows and plr_rownums are correct - new_order = [] - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - new_order.append(int(self.model.playlist_rows[row].note)) - assert new_order == [0, 1, 2, 4, 7, 3, 5, 6, 8, 9, 10] - - def test_move_rows_test8(self): - # move rows [7, 8, 10] → 5 - - self.model.move_rows([7, 8, 10], 5) - - # Check we have all rows and plr_rownums are correct - new_order = [] - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - new_order.append(int(self.model.playlist_rows[row].note)) - assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9] - - def test_move_rows_test9(self): - # move rows [1, 2, 3] → 0 - # Replicate issue 244 - - self.model.move_rows([0, 1, 2, 3], 0) - - # Check we have all rows and plr_rownums are correct - new_order = [] - for row in range(self.model.rowCount()): - assert row in self.model.playlist_rows - assert self.model.playlist_rows[row].row_number == row - new_order.append(int(self.model.playlist_rows[row].note)) - assert new_order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + ds.db.drop_all() def test_insert_header_row_end(self): # insert header row at end of playlist @@ -256,7 +160,11 @@ class TestMMMiscRowMove(unittest.TestCase): note_text = "test text" assert self.model.rowCount() == self.ROWS_TO_CREATE - self.model.insert_row(proposed_row_number=None, note=note_text) + self.model.insert_row_signal_handler( + InsertTrack( + playlist_id=self.playlist.playlist_id, track_id=None, note=note_text + ) + ) assert self.model.rowCount() == self.ROWS_TO_CREATE + 1 prd = self.model.playlist_rows[self.model.rowCount() - 1] # Test against edit_role because display_role for headers is @@ -274,7 +182,14 @@ class TestMMMiscRowMove(unittest.TestCase): note_text = "test text" insert_row = 6 - self.model.insert_row(proposed_row_number=insert_row, note=note_text) + # Fake selected row in model + self.model.selected_rows = [self.model.playlist_rows[insert_row]] + + self.model.insert_row_signal_handler( + InsertTrack( + playlist_id=self.playlist.playlist_id, track_id=None, note=note_text + ) + ) assert self.model.rowCount() == self.ROWS_TO_CREATE + 1 prd = self.model.playlist_rows[insert_row] # Test against edit_role because display_role for headers is @@ -290,11 +205,20 @@ class TestMMMiscRowMove(unittest.TestCase): note_text = "test text" insert_row = 6 - self.model.insert_row(proposed_row_number=insert_row, note=note_text) + self.model.insert_row_signal_handler( + InsertTrack( + playlist_id=self.playlist.playlist_id, track_id=None, note=note_text + ) + ) assert self.model.rowCount() == self.ROWS_TO_CREATE + 1 + # Fake selected row in model + self.model.selected_rows = [self.model.playlist_rows[insert_row]] + prd = self.model.playlist_rows[1] - self.model.add_track_to_header(insert_row, prd.track_id) + self.model.signal_add_track_to_header_handler( + TrackAndPlaylist(playlist_id=self.model.playlist_id, track_id=prd.track_id) + ) def test_reverse_row_groups_one_row(self): rows_to_move = [3] @@ -314,20 +238,26 @@ class TestMMMiscRowMove(unittest.TestCase): def test_move_one_row_between_playlists_to_end(self): from_rows = [3] to_row = self.ROWS_TO_CREATE - destination_playlist = "destination" + destination_playlist_name = "destination" model_src = self.model - with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist, template_id=0) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) - for row in range(self.ROWS_TO_CREATE): - model_dst.insert_row(proposed_row_number=row, note=str(row)) + playlist_dst = ds.playlist_create(destination_playlist_name, template_id=0) + model_dst = playlistmodel.PlaylistModel( + playlist_dst.playlist_id, is_template=False + ) + for row in range(self.ROWS_TO_CREATE): + model_dst.insert_row_signal_handler( + InsertTrack( + playlist_id=playlist_dst.playlist_id, track_id=None, note=str(row) + ) + ) - model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id) - model_dst.refresh_data(session) + model_src.move_rows_between_playlists( + from_rows, to_row, playlist_dst.playlist_id + ) - assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) - assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) + assert model_src.rowCount() == self.ROWS_TO_CREATE - len(from_rows) + assert model_dst.rowCount() == self.ROWS_TO_CREATE + len(from_rows) assert sorted([a.row_number for a in model_src.playlist_rows.values()]) == list( range(len(model_src.playlist_rows)) ) @@ -335,17 +265,23 @@ class TestMMMiscRowMove(unittest.TestCase): def test_move_one_row_between_playlists_to_middle(self): from_rows = [3] to_row = 2 - destination_playlist = "destination" + destination_playlist_name = "destination" model_src = self.model - with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist, template_id=0) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) - for row in range(self.ROWS_TO_CREATE): - model_dst.insert_row(proposed_row_number=row, note=str(row)) + playlist_dst = ds.playlist_create(destination_playlist_name, template_id=0) + model_dst = playlistmodel.PlaylistModel( + playlist_dst.playlist_id, is_template=False + ) + for row in range(self.ROWS_TO_CREATE): + model_dst.insert_row_signal_handler( + InsertTrack( + playlist_id=playlist_dst.playlist_id, track_id=None, note=str(row) + ) + ) - model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id) - model_dst.refresh_data(session) + model_src.move_rows_between_playlists( + from_rows, to_row, playlist_dst.playlist_id + ) # Check the rows of the destination model row_notes = [] @@ -355,24 +291,31 @@ class TestMMMiscRowMove(unittest.TestCase): ) row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole)) - assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) - assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) + assert model_src.rowCount() == self.ROWS_TO_CREATE - len(from_rows) + assert model_dst.rowCount() == self.ROWS_TO_CREATE + len(from_rows) assert [int(a) for a in row_notes] == [0, 1, 3, 2, 3, 4, 5, 6, 7, 8, 9, 10] def test_move_multiple_rows_between_playlists_to_end(self): from_rows = [1, 3, 4] to_row = 2 - destination_playlist = "destination" + destination_playlist_name = "destination" model_src = self.model - with db.Session() as session: - playlist_dst = Playlists(session, destination_playlist, template_id=0) - model_dst = playlistmodel.PlaylistModel(playlist_dst.id, is_template=False) - for row in range(self.ROWS_TO_CREATE): - model_dst.insert_row(proposed_row_number=row, note=str(row)) - model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id) - model_dst.refresh_data(session) + playlist_dst = ds.playlist_create(destination_playlist_name, template_id=0) + model_dst = playlistmodel.PlaylistModel( + playlist_dst.playlist_id, is_template=False + ) + for row in range(self.ROWS_TO_CREATE): + model_dst.insert_row_signal_handler( + InsertTrack( + playlist_id=playlist_dst.playlist_id, track_id=None, note=str(row) + ) + ) + + model_src.move_rows_between_playlists( + from_rows, to_row, playlist_dst.playlist_id + ) # Check the rows of the destination model row_notes = [] @@ -382,8 +325,8 @@ class TestMMMiscRowMove(unittest.TestCase): ) row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole)) - assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows) - assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows) + assert model_src.rowCount() == self.ROWS_TO_CREATE - len(from_rows) + assert model_dst.rowCount() == self.ROWS_TO_CREATE + len(from_rows) assert [int(a) for a in row_notes] == [ 0, 1, @@ -400,22 +343,3 @@ class TestMMMiscRowMove(unittest.TestCase): 9, 10, ] - - -# # def test_edit_header(monkeypatch, session): # edit header row in middle of playlist - -# # monkeypatch.setattr(playlistmodel, "Session", session) -# # note_text = "test text" -# # initial_row_count = 11 -# # insert_row = 6 - -# # model = create_model_with_playlist_rows(session, initial_row_count) -# # model.insert_header_row(insert_row, note_text) -# # assert model.rowCount() == initial_row_count + 1 -# # prd = model.playlist_rows[insert_row] -# # # Test against edit_role because display_role for headers is -# # # handled differently (sets up row span) -# # assert ( -# # model.edit_role(model.rowCount(), playlistmodel.Col.NOTE.value, prd) -# # == note_text -# # ) diff --git a/tests/test_queries.py b/tests/test_queries.py index 1e84a61..a636ab4 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -8,14 +8,10 @@ import unittest # Third party imports # App imports -from app.models import ( - db, - Playdates, - Tracks, -) from classes import ( Filter, ) +import ds class MyTestCase(unittest.TestCase): @@ -23,43 +19,42 @@ class MyTestCase(unittest.TestCase): def setUpClass(cls): """Runs once before any test in this class""" - db.create_all() + ds.db.create_all() - with db.Session() as session: - # Create some track entries - _ = Tracks(**dict( - session=session, - artist="a", - bitrate=0, - duration=100, - fade_at=0, - path="/alpha/bravo/charlie", - silence_at=0, - start_gap=0, - title="abc" - )) - track2 = Tracks(**dict( - session=session, - artist="a", - bitrate=0, - duration=100, - fade_at=0, - path="/xray/yankee/zulu", - silence_at=0, - start_gap=0, - title="xyz" - )) - track2_id = track2.id - # Add playdates - # Track 2 played just over a year ago - just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367) - _ = Playdates(session, track2_id, when=just_over_a_year_ago) + # Create some track entries + track1_meta = dict( + artist="a", + bitrate=0, + duration=100, + fade_at=0, + path="/alpha/bravo/charlie", + silence_at=0, + start_gap=0, + title="abc", + ) + _ = ds.track_create(track1_meta) + track2_meta = dict( + artist="a", + bitrate=0, + duration=100, + fade_at=0, + path="/xray/yankee/zulu", + silence_at=0, + start_gap=0, + title="xyz", + ) + track2 = ds.track_create(track2_meta) + + # Add playdates + # Track 2 played just over a year ago + just_over_a_year_ago = dt.datetime.now() - dt.timedelta(days=367) + ds.playdates_update(track2.track_id, when=just_over_a_year_ago) @classmethod def tearDownClass(cls): """Runs once after all tests""" - db.drop_all() + ds.db.drop_all() def setUp(self): """Runs before each test""" @@ -76,55 +71,49 @@ class MyTestCase(unittest.TestCase): filter = Filter(path="alpha", last_played_comparator="never") - with db.Session() as session: - results = Tracks.get_filtered_tracks(session, filter) - assert len(results) == 1 - assert 'alpha' in results[0].path + results = ds.tracks_filtered(filter) + assert len(results) == 1 + assert "alpha" in results[0].path def test_search_path_2(self): """Search for unplayed track that doesn't exist""" filter = Filter(path="xray", last_played_comparator="never") - with db.Session() as session: - results = Tracks.get_filtered_tracks(session, filter) - assert len(results) == 0 + results = ds.tracks_filtered(filter) + assert len(results) == 0 def test_played_over_a_year_ago(self): """Search for tracks played over a year ago""" filter = Filter(last_played_unit="years", last_played_number=1) - with db.Session() as session: - results = Tracks.get_filtered_tracks(session, filter) - assert len(results) == 1 - assert 'zulu' in results[0].path + results = ds.tracks_filtered(filter) + assert len(results) == 1 + assert "zulu" in results[0].path def test_played_over_two_years_ago(self): """Search for tracks played over 2 years ago""" filter = Filter(last_played_unit="years", last_played_number=2) - with db.Session() as session: - results = Tracks.get_filtered_tracks(session, filter) - assert len(results) == 0 + results = ds.tracks_filtered(filter) + assert len(results) == 0 def test_never_played(self): """Search for tracks never played""" filter = Filter(last_played_comparator="never") - with db.Session() as session: - results = Tracks.get_filtered_tracks(session, filter) - assert len(results) == 1 - assert 'alpha' in results[0].path + results = ds.tracks_filtered(filter) + assert len(results) == 1 + assert "alpha" in results[0].path def test_played_anytime(self): """Search for tracks played over a year ago""" filter = Filter(last_played_comparator="Any time") - with db.Session() as session: - results = Tracks.get_filtered_tracks(session, filter) - assert len(results) == 1 - assert 'zulu' in results[0].path + results = ds.tracks_filtered(filter) + assert len(results) == 1 + assert "zulu" in results[0].path diff --git a/tests/test_ui.py b/tests/test_ui.py index 508c700..0063276 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -10,12 +10,8 @@ from pytestqt.plugin import QtBot # type: ignore # App imports from app import playlistmodel, utilities -from app.models import ( - db, - Playlists, - Tracks, -) -from app import musicmuster +from app import ds, musicmuster +from classes import InsertTrack # Custom fixture to adapt qtbot for use with unittest.TestCase @@ -44,13 +40,13 @@ def with_updown(function): @pytest.mark.usefixtures("qtbot_adapter") class MyTestCase(unittest.TestCase): def up(self): - db.create_all() + ds.db.create_all() self.widget = musicmuster.Window() # self.widget.show() # Add two tracks to database - self.tracks = { - 1: { + self.track1 = ds.track_create( + { "path": "testdata/isa.mp3", "title": "I'm so afraid", "artist": "Fleetwood Mac", @@ -59,8 +55,10 @@ class MyTestCase(unittest.TestCase): "start_gap": 60, "fade_at": 236263, "silence_at": 260343, - }, - 2: { + } + ) + self.track2 = ds.track_create( + { "path": "testdata/mom.mp3", "title": "Man of Mystery", "artist": "The Shadows", @@ -69,19 +67,11 @@ class MyTestCase(unittest.TestCase): "start_gap": 70, "fade_at": 115000, "silence_at": 118000, - }, - } - - with db.Session() as session: - for track in self.tracks.values(): - db_track = Tracks(session=session, **track) - session.add(db_track) - track["id"] = db_track.id - - session.commit() + } + ) def down(self): - db.drop_all() + ds.db.drop_all() @with_updown def test_init(self): @@ -89,11 +79,10 @@ class MyTestCase(unittest.TestCase): playlist_name = "test_init playlist" - with db.Session() as session: - playlist = Playlists(session, playlist_name, template_id=0) - self.widget._open_playlist(playlist, is_template=False) - with self.qtbot.waitExposed(self.widget): - self.widget.show() + playlist = ds.playlist_create(playlist_name, template_id=0) + self.widget._open_playlist(playlist, is_template=False) + with self.qtbot.waitExposed(self.widget): + self.widget.show() @with_updown def test_save_and_restore(self): @@ -102,27 +91,28 @@ class MyTestCase(unittest.TestCase): note_text = "my note" playlist_name = "test_save_and_restore playlist" - with db.Session() as session: - playlist = Playlists(session, playlist_name, template_id=0) - model = playlistmodel.PlaylistModel(playlist.id, is_template=False) + playlist = ds.playlist_create(playlist_name, template_id=0) + model = playlistmodel.PlaylistModel(playlist.playlist_id, is_template=False) - # Add a track with a note - model.insert_row( - proposed_row_number=0, track_id=self.tracks[1]["id"], note=note_text + # Add a track with a note + model.insert_row_signal_handler( + InsertTrack( + playlist_id=playlist.playlist_id, + track_id=self.track1.track_id, + note=note_text, ) + ) - # We need to commit the session before re-querying - session.commit() - - # Retrieve playlist - all_playlists = Playlists.get_all(session) - assert len(all_playlists) == 1 - retrieved_playlist = all_playlists[0] - assert len(retrieved_playlist.rows) == 1 - paths = [a.track.path for a in retrieved_playlist.rows] - assert self.tracks[1]["path"] in paths - notes = [a.note for a in retrieved_playlist.rows] - assert note_text in notes + # Retrieve playlist + all_playlists = ds.playlists_all() + assert len(all_playlists) == 1 + retrieved_playlist = all_playlists[0] + playlist_rows = ds.playlistrows_by_playlist(retrieved_playlist.playlist_id) + assert len(playlist_rows) == 1 + paths = [a.track.path for a in playlist_rows] + assert self.track1.path in paths + notes = [a.note for a in playlist_rows] + assert note_text in notes @with_updown def test_utilities(self): @@ -132,217 +122,5 @@ class MyTestCase(unittest.TestCase): Config.ROOT = os.path.join(os.path.dirname(__file__), "testdata") - with db.Session() as session: - utilities.check_db(session) - utilities.update_bitrates(session) - - -# def test_meta_all_clear(qtbot, session): -# # Create playlist -# playlist = models.Playlists(session, "my playlist", template_id=0) -# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) - -# # Add some tracks -# # Need to commit session after each one so that new row is found -# # for subsequent inserts -# track1_path = "/a/b/c" -# track1 = models.Tracks(session, track1_path) -# playlist_tab.insert_track(session, track1) -# session.commit() -# track2_path = "/d/e/f" -# track2 = models.Tracks(session, track2_path) -# playlist_tab.insert_track(session, track2) -# session.commit() -# track3_path = "/h/i/j" -# track3 = models.Tracks(session, track3_path) -# playlist_tab.insert_track(session, track3) -# session.commit() - -# assert playlist_tab._get_current_track_row() is None -# assert playlist_tab._get_next_track_row() is None -# assert playlist_tab._get_notes_rows() == [] -# assert playlist_tab._get_played_track_rows() == [] -# assert len(playlist_tab._get_unreadable_track_rows()) == 3 - - -# def test_meta(qtbot, session): -# # Create playlist -# playlist = playlists.Playlists(session, "my playlist", -# template_id=0) -# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) - -# # Add some tracks -# track1_path = "/a/b/c" -# track1 = models.Tracks(session, track1_path) -# playlist_tab.insert_track(session, track1) -# session.commit() -# track2_path = "/d/e/f" -# track2 = models.Tracks(session, track2_path) -# playlist_tab.insert_track(session, track2) -# session.commit() -# track3_path = "/h/i/j" -# track3 = models.Tracks(session, track3_path) -# playlist_tab.insert_track(session, track3) -# session.commit() - -# assert len(playlist_tab._get_unreadable_track_rows()) == 3 - -# assert playlist_tab._get_played_track_rows() == [] -# assert playlist_tab._get_current_track_row() is None -# assert playlist_tab._get_next_track_row() is None -# assert playlist_tab._get_notes_rows() == [] - -# playlist_tab._set_played_row(0) -# assert playlist_tab._get_played_track_rows() == [0] -# assert playlist_tab._get_current_track_row() is None -# assert playlist_tab._get_next_track_row() is None -# assert playlist_tab._get_notes_rows() == [] - -# # Add a note -# note_text = "my note" -# note_row = 7 # will be added as row 3 -# note = models.Notes(session, playlist.id, note_row, note_text) -# playlist_tab._insert_note(session, note) - -# assert playlist_tab._get_played_track_rows() == [0] -# assert playlist_tab._get_current_track_row() is None -# assert playlist_tab._get_next_track_row() is None -# assert playlist_tab._get_notes_rows() == [3] - -# playlist_tab._set_next_track_row(1) -# assert playlist_tab._get_played_track_rows() == [0] -# assert playlist_tab._get_current_track_row() is None -# assert playlist_tab._get_next_track_row() == 1 -# assert playlist_tab._get_notes_rows() == [3] - -# playlist_tab._set_current_track_row(2) -# assert playlist_tab._get_played_track_rows() == [0] -# assert playlist_tab._get_current_track_row() == 2 -# assert playlist_tab._get_next_track_row() == 1 -# assert playlist_tab._get_notes_rows() == [3] - -# playlist_tab._clear_played_row_status(0) -# assert playlist_tab._get_played_track_rows() == [] -# assert playlist_tab._get_current_track_row() == 2 -# assert playlist_tab._get_next_track_row() == 1 -# assert playlist_tab._get_notes_rows() == [3] - -# playlist_tab._meta_clear_next() -# assert playlist_tab._get_played_track_rows() == [] -# assert playlist_tab._get_current_track_row() == 2 -# assert playlist_tab._get_next_track_row() is None -# assert playlist_tab._get_notes_rows() == [3] - -# playlist_tab._clear_current_track_row() -# assert playlist_tab._get_played_track_rows() == [] -# assert playlist_tab._get_current_track_row() is None -# assert playlist_tab._get_next_track_row() is None -# assert playlist_tab._get_notes_rows() == [3] - -# # Test clearing again has no effect -# playlist_tab._clear_current_track_row() -# assert playlist_tab._get_played_track_rows() == [] -# assert playlist_tab._get_current_track_row() is None -# assert playlist_tab._get_next_track_row() is None -# assert playlist_tab._get_notes_rows() == [3] - - -# def test_clear_next(qtbot, session): -# # Create playlist -# playlist = models.Playlists(session, "my playlist", template_id=0) -# playlist_tab = playlists.PlaylistTab(None, session, playlist.id) - -# # Add some tracks -# track1_path = "/a/b/c" -# track1 = models.Tracks(session, track1_path) -# playlist_tab.insert_track(session, track1) -# session.commit() -# track2_path = "/d/e/f" -# track2 = models.Tracks(session, track2_path) -# playlist_tab.insert_track(session, track2) -# session.commit() - -# playlist_tab._set_next_track_row(1) -# assert playlist_tab._get_next_track_row() == 1 - -# playlist_tab.clear_next(session) -# assert playlist_tab._get_next_track_row() is None - - -# def test_get_selected_row(qtbot, monkeypatch, session): -# monkeypatch.setattr(musicmuster, "Session", session) -# monkeypatch.setattr(playlists, "Session", session) - -# # Create playlist and playlist_tab -# window = musicmuster.Window() -# playlist = models.Playlists(session, "test playlist", template_id=0) -# playlist_tab = playlists.PlaylistTab(window, session, playlist.id) - -# # Add some tracks -# track1_path = "/a/b/c" -# track1 = models.Tracks(session, track1_path) -# playlist_tab.insert_track(session, track1) -# session.commit() -# track2_path = "/d/e/f" -# track2 = models.Tracks(session, track2_path) -# playlist_tab.insert_track(session, track2) -# session.commit() - -# qtbot.addWidget(playlist_tab) -# with qtbot.waitExposed(window): -# window.show() -# row0_item0 = playlist_tab.item(0, 0) -# assert row0_item0 is not None -# rect = playlist_tab.visualItemRect(row0_item0) -# qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center()) -# row_number = playlist_tab.get_selected_row() -# assert row_number == 0 - - -# def test_set_next(qtbot, monkeypatch, session): -# monkeypatch.setattr(musicmuster, "Session", session) -# monkeypatch.setattr(playlists, "Session", session) -# seed2tracks(session) - -# playlist_name = "test playlist" -# # Create testing playlist -# window = musicmuster.Window() -# playlist = models.Playlists(session, playlist_name, template_id=0) -# playlist_tab = playlists.PlaylistTab(window, session, playlist.id) -# idx = window.tabPlaylist.addTab(playlist_tab, playlist_name) -# window.tabPlaylist.setCurrentIndex(idx) -# qtbot.addWidget(playlist_tab) - -# # Add some tracks -# track1 = models.Tracks.get_by_filename(session, "isa.mp3") -# track1_title = track1.title -# assert track1_title - -# playlist_tab.insert_track(session, track1) -# session.commit() -# track2 = models.Tracks.get_by_filename(session, "mom.mp3") -# playlist_tab.insert_track(session, track2) - -# with qtbot.waitExposed(window): -# window.show() - -# row0_item2 = playlist_tab.item(0, 2) -# assert row0_item2 is not None -# rect = playlist_tab.visualItemRect(row0_item2) -# qtbot.mouseClick(playlist_tab.viewport(), Qt.LeftButton, pos=rect.center()) -# selected_title = playlist_tab.get_selected_title() -# assert selected_title == track1_title - -# qtbot.keyPress(playlist_tab.viewport(), "N", modifier=Qt.ControlModifier) -# qtbot.wait(1000) - - -# def test_kae(monkeypatch, session): -# # monkeypatch.setattr(dbconfig, "Session", session) -# monkeypatch.setattr(musicmuster, "Session", session) - -# musicmuster.Window.kae() -# # monkeypatch.setattr(musicmuster, "Session", session) -# # monkeypatch.setattr(dbconfig, "Session", session) -# # monkeypatch.setattr(models, "Session", session) -# # monkeypatch.setattr(playlists, "Session", session) + utilities.check_db() + utilities.update_bitrates() diff --git a/uv.lock b/uv.lock index b6ce2e8..414f508 100644 --- a/uv.lock +++ b/uv.lock @@ -16,16 +16,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.15.1" +version = "1.15.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/ed/901044acb892caa5604bf818d2da9ab0df94ef606c6059fdf367894ebf60/alembic-1.15.1.tar.gz", hash = "sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49", size = 1924789, upload-time = "2025-03-04T22:02:38.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/57/e314c31b261d1e8a5a5f1908065b4ff98270a778ce7579bd4254477209a7/alembic-1.15.2.tar.gz", hash = "sha256:1c72391bbdeffccfe317eefba686cb9a3c078005478885413b95c3b26c57a8a7", size = 1925573 } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/f7/d398fae160568472ddce0b3fde9c4581afc593019a6adc91006a66406991/alembic-1.15.1-py3-none-any.whl", hash = "sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe", size = 231753, upload-time = "2025-03-04T22:02:41.673Z" }, + { url = "https://files.pythonhosted.org/packages/41/18/d89a443ed1ab9bcda16264716f809c663866d4ca8de218aa78fd50b38ead/alembic-1.15.2-py3-none-any.whl", hash = "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53", size = 231911 }, ] [[package]] @@ -39,11 +39,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.1.0" +version = "25.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562, upload-time = "2025-01-25T11:30:12.508Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152, upload-time = "2025-01-25T11:30:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, ] [[package]] @@ -141,31 +141,31 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.12" +version = "7.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941, upload-time = "2025-02-11T14:47:03.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673, upload-time = "2025-02-11T14:45:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945, upload-time = "2025-02-11T14:46:01.869Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484, upload-time = "2025-02-11T14:46:03.527Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525, upload-time = "2025-02-11T14:46:05.973Z" }, - { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545, upload-time = "2025-02-11T14:46:07.79Z" }, - { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179, upload-time = "2025-02-11T14:46:11.853Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288, upload-time = "2025-02-11T14:46:13.411Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032, upload-time = "2025-02-11T14:46:15.005Z" }, - { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315, upload-time = "2025-02-11T14:46:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099, upload-time = "2025-02-11T14:46:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511, upload-time = "2025-02-11T14:46:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729, upload-time = "2025-02-11T14:46:22.258Z" }, - { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988, upload-time = "2025-02-11T14:46:23.999Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697, upload-time = "2025-02-11T14:46:25.617Z" }, - { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033, upload-time = "2025-02-11T14:46:28.069Z" }, - { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535, upload-time = "2025-02-11T14:46:29.818Z" }, - { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192, upload-time = "2025-02-11T14:46:31.563Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627, upload-time = "2025-02-11T14:46:33.145Z" }, - { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033, upload-time = "2025-02-11T14:46:35.79Z" }, - { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240, upload-time = "2025-02-11T14:46:38.119Z" }, - { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552, upload-time = "2025-02-11T14:47:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 }, + { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 }, + { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 }, + { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 }, + { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 }, + { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 }, + { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 }, + { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 }, + { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 }, + { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 }, + { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 }, + { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 }, + { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 }, + { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 }, + { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 }, + { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 }, + { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 }, + { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 }, + { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 }, ] [[package]] @@ -232,16 +232,16 @@ wheels = [ [[package]] name = "flake8" -version = "7.1.2" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/16/3f2a0bb700ad65ac9663262905a025917c020a3f92f014d2ba8964b4602c/flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd", size = 48119, upload-time = "2025-02-16T18:45:44.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/c4/5842fc9fc94584c455543540af62fd9900faade32511fab650e9891ec225/flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426", size = 48177 } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/f8/08d37b2cd89da306e3520bd27f8a85692122b42b56c0c2c3784ff09c022f/flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", size = 57745, upload-time = "2025-02-16T18:45:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", size = 57786 }, ] [[package]] @@ -296,11 +296,11 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, ] [[package]] @@ -318,7 +318,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.0.1" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -332,9 +332,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/33/1901c9a842b301d8674f367dee597e654e402548a903faf7280aae8fc2d4/ipython-9.0.1.tar.gz", hash = "sha256:377ea91c8226b48dc9021ac9846a64761abc7ddf74c5efe38e6eb06f6e052f3a", size = 4365847, upload-time = "2025-03-03T08:17:03.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ce/012a0f40ca58a966f87a6e894d6828e2817657cbdf522b02a5d3a87d92ce/ipython-9.0.2.tar.gz", hash = "sha256:ec7b479e3e5656bf4f58c652c120494df1820f4f28f522fb7ca09e213c2aab52", size = 4366102 } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/39/fda74f8215ef94a812dd780073c61a826a88a01e51f627a3454f7ae6951d/ipython-9.0.1-py3-none-any.whl", hash = "sha256:3e878273824b52e0a2280ed84f8193aba8c4ba9a6f45a438348a3d5ef1a34bd0", size = 600186, upload-time = "2025-03-03T08:17:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/20/3a/917cb9e72f4e1a4ea13c862533205ae1319bd664119189ee5cc9e4e95ebf/ipython-9.0.2-py3-none-any.whl", hash = "sha256:143ef3ea6fb1e1bffb4c74b114051de653ffb7737a3f7ab1670e657ca6ae8c44", size = 600524 }, ] [[package]] @@ -629,30 +629,30 @@ wheels = [ [[package]] name = "numpy" -version = "2.2.3" +version = "2.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/90/8956572f5c4ae52201fdec7ba2044b2c882832dcec7d5d0922c9e9acf2de/numpy-2.2.3.tar.gz", hash = "sha256:dbdc15f0c81611925f382dfa97b3bd0bc2c1ce19d4fe50482cb0ddc12ba30020", size = 20262700, upload-time = "2025-02-13T17:17:41.558Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/8b/88b98ed534d6a03ba8cddb316950fe80842885709b58501233c29dfa24a9/numpy-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bfdb06b395385ea9b91bf55c1adf1b297c9fdb531552845ff1d3ea6e40d5aba", size = 20916001, upload-time = "2025-02-13T16:51:52.612Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b4/def6ec32c725cc5fbd8bdf8af80f616acf075fe752d8a23e895da8c67b70/numpy-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23c9f4edbf4c065fddb10a4f6e8b6a244342d95966a48820c614891e5059bb50", size = 14130721, upload-time = "2025-02-13T16:52:31.998Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/70af0acc86495b25b672d403e12cb25448d79a2b9658f4fc45e845c397a8/numpy-2.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:a0c03b6be48aaf92525cccf393265e02773be8fd9551a2f9adbe7db1fa2b60f1", size = 5130999, upload-time = "2025-02-13T16:52:41.545Z" }, - { url = "https://files.pythonhosted.org/packages/2e/69/d96c006fb73c9a47bcb3611417cf178049aae159afae47c48bd66df9c536/numpy-2.2.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:2376e317111daa0a6739e50f7ee2a6353f768489102308b0d98fcf4a04f7f3b5", size = 6665299, upload-time = "2025-02-13T16:52:54.96Z" }, - { url = "https://files.pythonhosted.org/packages/5a/3f/d8a877b6e48103733ac224ffa26b30887dc9944ff95dffdfa6c4ce3d7df3/numpy-2.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fb62fe3d206d72fe1cfe31c4a1106ad2b136fcc1606093aeab314f02930fdf2", size = 14064096, upload-time = "2025-02-13T16:53:29.678Z" }, - { url = "https://files.pythonhosted.org/packages/e4/43/619c2c7a0665aafc80efca465ddb1f260287266bdbdce517396f2f145d49/numpy-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52659ad2534427dffcc36aac76bebdd02b67e3b7a619ac67543bc9bfe6b7cdb1", size = 16114758, upload-time = "2025-02-13T16:54:03.466Z" }, - { url = "https://files.pythonhosted.org/packages/d9/79/ee4fe4f60967ccd3897aa71ae14cdee9e3c097e3256975cc9575d393cb42/numpy-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b416af7d0ed3271cad0f0a0d0bee0911ed7eba23e66f8424d9f3dfcdcae1304", size = 15259880, upload-time = "2025-02-13T16:54:26.744Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c8/8b55cf05db6d85b7a7d414b3d1bd5a740706df00bfa0824a08bf041e52ee/numpy-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1402da8e0f435991983d0a9708b779f95a8c98c6b18a171b9f1be09005e64d9d", size = 17876721, upload-time = "2025-02-13T16:54:53.751Z" }, - { url = "https://files.pythonhosted.org/packages/21/d6/b4c2f0564b7dcc413117b0ffbb818d837e4b29996b9234e38b2025ed24e7/numpy-2.2.3-cp313-cp313-win32.whl", hash = "sha256:136553f123ee2951bfcfbc264acd34a2fc2f29d7cdf610ce7daf672b6fbaa693", size = 6290195, upload-time = "2025-02-13T16:58:31.683Z" }, - { url = "https://files.pythonhosted.org/packages/97/e7/7d55a86719d0de7a6a597949f3febefb1009435b79ba510ff32f05a8c1d7/numpy-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5b732c8beef1d7bc2d9e476dbba20aaff6167bf205ad9aa8d30913859e82884b", size = 12619013, upload-time = "2025-02-13T16:58:50.693Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1f/0b863d5528b9048fd486a56e0b97c18bf705e88736c8cea7239012119a54/numpy-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:435e7a933b9fda8126130b046975a968cc2d833b505475e588339e09f7672890", size = 20944621, upload-time = "2025-02-13T16:55:27.593Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/b478c384f7a0a2e0736177aafc97dc9152fc036a3fdb13f5a3ab225f1494/numpy-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7678556eeb0152cbd1522b684dcd215250885993dd00adb93679ec3c0e6e091c", size = 14142502, upload-time = "2025-02-13T16:55:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/fb/61/2d9a694a0f9cd0a839501d362de2a18de75e3004576a3008e56bdd60fcdb/numpy-2.2.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2e8da03bd561504d9b20e7a12340870dfc206c64ea59b4cfee9fceb95070ee94", size = 5176293, upload-time = "2025-02-13T16:56:01.372Z" }, - { url = "https://files.pythonhosted.org/packages/33/35/51e94011b23e753fa33f891f601e5c1c9a3d515448659b06df9d40c0aa6e/numpy-2.2.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:c9aa4496fd0e17e3843399f533d62857cef5900facf93e735ef65aa4bbc90ef0", size = 6691874, upload-time = "2025-02-13T16:56:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/ff/cf/06e37619aad98a9d03bd8d65b8e3041c3a639be0f5f6b0a0e2da544538d4/numpy-2.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ca91d61a4bf61b0f2228f24bbfa6a9facd5f8af03759fe2a655c50ae2c6610", size = 14036826, upload-time = "2025-02-13T16:56:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/0c/93/5d7d19955abd4d6099ef4a8ee006f9ce258166c38af259f9e5558a172e3e/numpy-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deaa09cd492e24fd9b15296844c0ad1b3c976da7907e1c1ed3a0ad21dded6f76", size = 16096567, upload-time = "2025-02-13T16:56:58.035Z" }, - { url = "https://files.pythonhosted.org/packages/af/53/d1c599acf7732d81f46a93621dab6aa8daad914b502a7a115b3f17288ab2/numpy-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:246535e2f7496b7ac85deffe932896a3577be7af8fb7eebe7146444680297e9a", size = 15242514, upload-time = "2025-02-13T16:57:22.124Z" }, - { url = "https://files.pythonhosted.org/packages/53/43/c0f5411c7b3ea90adf341d05ace762dad8cb9819ef26093e27b15dd121ac/numpy-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:daf43a3d1ea699402c5a850e5313680ac355b4adc9770cd5cfc2940e7861f1bf", size = 17872920, upload-time = "2025-02-13T16:57:49.308Z" }, - { url = "https://files.pythonhosted.org/packages/5b/57/6dbdd45ab277aff62021cafa1e15f9644a52f5b5fc840bc7591b4079fb58/numpy-2.2.3-cp313-cp313t-win32.whl", hash = "sha256:cf802eef1f0134afb81fef94020351be4fe1d6681aadf9c5e862af6602af64ef", size = 6346584, upload-time = "2025-02-13T16:58:02.02Z" }, - { url = "https://files.pythonhosted.org/packages/97/9b/484f7d04b537d0a1202a5ba81c6f53f1846ae6c63c2127f8df869ed31342/numpy-2.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:aee2512827ceb6d7f517c8b85aa5d3923afe8fc7a57d028cffcd522f1c6fd082", size = 12706784, upload-time = "2025-02-13T16:58:21.038Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 }, + { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 }, + { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 }, + { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 }, + { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 }, + { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 }, + { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 }, + { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 }, + { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 }, + { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 }, + { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 }, + { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 }, + { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 }, + { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 }, + { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 }, + { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 }, + { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 }, + { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 }, + { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 }, ] [[package]] @@ -733,11 +733,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.3.6" +version = "4.3.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, ] [[package]] @@ -812,11 +812,11 @@ wheels = [ [[package]] name = "pycodestyle" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232, upload-time = "2024-08-04T20:26:54.576Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/6e/1f4a62078e4d95d82367f24e685aef3a672abfd27d1a868068fed4ed2254/pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae", size = 39312 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284, upload-time = "2024-08-04T20:26:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", size = 31424 }, ] [[package]] @@ -854,11 +854,11 @@ wheels = [ [[package]] name = "pyflakes" -version = "3.2.0" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788, upload-time = "2024-01-05T00:28:47.703Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cc/1df338bd7ed1fa7c317081dcf29bf2f01266603b301e6858856d346a12b3/pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b", size = 64175 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", size = 63164 }, ] [[package]] @@ -1008,15 +1008,15 @@ wheels = [ [[package]] name = "pytest-cov" -version = "6.0.0" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857 } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" }, + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, ] [[package]] @@ -1084,68 +1084,68 @@ wheels = [ [[package]] name = "rapidfuzz" -version = "3.12.2" +version = "3.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/be/8dff25a6157dfbde9867720b1282157fe7b809e085130bb89d7655c62186/rapidfuzz-3.12.2.tar.gz", hash = "sha256:b0ba1ccc22fff782e7152a3d3d0caca44ec4e32dc48ba01c560b8593965b5aa3", size = 57907839, upload-time = "2025-03-02T18:32:28.366Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226 } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/59/2ea3b5bb82798eae73d6ee892264ebfe42727626c1f0e96c77120f0d5cf6/rapidfuzz-3.12.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:941f31038dba5d3dedcfcceba81d61570ad457c873a24ceb13f4f44fcb574260", size = 1936870, upload-time = "2025-03-02T18:30:28.423Z" }, - { url = "https://files.pythonhosted.org/packages/54/85/4e486bf9ea05e771ad231731305ed701db1339157f630b76b246ce29cf71/rapidfuzz-3.12.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe2dfc454ee51ba168a67b1e92b72aad251e45a074972cef13340bbad2fd9438", size = 1424231, upload-time = "2025-03-02T18:30:30.144Z" }, - { url = "https://files.pythonhosted.org/packages/dc/60/aeea3eed402c40a8cf055d554678769fbee0dd95c22f04546070a22bb90e/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78fafaf7f5a48ee35ccd7928339080a0136e27cf97396de45259eca1d331b714", size = 1398055, upload-time = "2025-03-02T18:30:31.999Z" }, - { url = "https://files.pythonhosted.org/packages/33/6b/757106f4c21fe3f20ce13ba3df560da60e52fe0dc390fd22bf613761669c/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0c7989ff32c077bb8fd53253fd6ca569d1bfebc80b17557e60750e6909ba4fe", size = 5526188, upload-time = "2025-03-02T18:30:34.002Z" }, - { url = "https://files.pythonhosted.org/packages/1e/a2/7c680cdc5532746dba67ecf302eed975252657094e50ae334fa9268352e8/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96fa00bc105caa34b6cd93dca14a29243a3a7f0c336e4dcd36348d38511e15ac", size = 1648483, upload-time = "2025-03-02T18:30:36.197Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/ce942a1448b1a75d64af230dd746dede502224dd29ca9001665bbfd4bee6/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bccfb30c668620c5bc3490f2dc7d7da1cca0ead5a9da8b755e2e02e2ef0dff14", size = 1676076, upload-time = "2025-03-02T18:30:38.335Z" }, - { url = "https://files.pythonhosted.org/packages/ba/71/81f77b08333200be6984b6cdf2bdfd7cfca4943f16b478a2f7838cba8d66/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f9b0adc3d894beb51f5022f64717b6114a6fabaca83d77e93ac7675911c8cc5", size = 3114169, upload-time = "2025-03-02T18:30:40.485Z" }, - { url = "https://files.pythonhosted.org/packages/01/16/f3f34b207fdc8c61a33f9d2d61fc96b62c7dadca88bda1df1be4b94afb0b/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32691aa59577f42864d5535cb6225d0f47e2c7bff59cf4556e5171e96af68cc1", size = 2485317, upload-time = "2025-03-02T18:30:42.392Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/b954f0766f644eb8dd8df44703e024ab4f5f15a8f8f5ea969963dd036f50/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:758b10380ad34c1f51753a070d7bb278001b5e6fcf544121c6df93170952d705", size = 7844495, upload-time = "2025-03-02T18:30:44.732Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8f/1dc604d05e07150a02b56a8ffc47df75ce316c65467259622c9edf098451/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:50a9c54c0147b468363119132d514c5024fbad1ed8af12bd8bd411b0119f9208", size = 2873242, upload-time = "2025-03-02T18:30:47.208Z" }, - { url = "https://files.pythonhosted.org/packages/78/a9/9c649ace4b7f885e0a5fdcd1f33b057ebd83ecc2837693e6659bd944a2bb/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e3ceb87c11d2d0fbe8559bb795b0c0604b84cfc8bb7b8720b5c16e9e31e00f41", size = 3519124, upload-time = "2025-03-02T18:30:49.175Z" }, - { url = "https://files.pythonhosted.org/packages/f5/81/ce0b774e540a2e22ec802e383131d7ead18347197304d584c4ccf7b8861a/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7c9a003002434889255ff5676ca0f8934a478065ab5e702f75dc42639505bba", size = 4557831, upload-time = "2025-03-02T18:30:51.24Z" }, - { url = "https://files.pythonhosted.org/packages/13/28/7bf0ee8d35efa7ab14e83d1795cdfd54833aa0428b6f87e987893136c372/rapidfuzz-3.12.2-cp313-cp313-win32.whl", hash = "sha256:cf165a76870cd875567941cf861dfd361a0a6e6a56b936c5d30042ddc9def090", size = 1842802, upload-time = "2025-03-02T18:30:53.185Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7e/792d609484776c8a40e1695ebd28b62196be9f8347b785b9104604dc7268/rapidfuzz-3.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:55bcc003541f5f16ec0a73bf6de758161973f9e8d75161954380738dd147f9f2", size = 1615808, upload-time = "2025-03-02T18:30:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/4b/43/ca3d1018b392f49131843648e10b08ace23afe8dad3bee5f136e4346b7cd/rapidfuzz-3.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:69f6ecdf1452139f2b947d0c169a605de578efdb72cbb2373cb0a94edca1fd34", size = 863535, upload-time = "2025-03-02T18:30:57.992Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282 }, + { url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274 }, + { url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854 }, + { url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962 }, + { url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016 }, + { url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414 }, + { url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179 }, + { url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856 }, + { url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107 }, + { url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192 }, + { url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876 }, + { url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077 }, + { url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066 }, + { url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100 }, + { url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976 }, ] [[package]] name = "rich" -version = "13.9.4" +version = "14.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, ] [[package]] name = "setuptools" -version = "75.8.2" +version = "78.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/53/43d99d7687e8cdef5ab5f9ec5eaf2c0423c2b35133a2b7e7bc276fc32b21/setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2", size = 1344083, upload-time = "2025-02-26T20:45:19.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/0db4da3bc908df06e5efae42b44e75c81dd52716e10192ff36d0c1c8e379/setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54", size = 1367827 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385, upload-time = "2025-02-26T20:45:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/54/21/f43f0a1fa8b06b32812e0975981f4677d28e0f3271601dc88ac5a5b83220/setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8", size = 1256108 }, ] [[package]] name = "sqlalchemy" -version = "2.0.38" +version = "2.0.40" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/08/9a90962ea72acd532bda71249a626344d855c4032603924b1b547694b837/sqlalchemy-2.0.38.tar.gz", hash = "sha256:e5a4d82bdb4bf1ac1285a68eab02d253ab73355d9f0fe725a97e1e0fa689decb", size = 9634782, upload-time = "2025-02-06T20:10:06.676Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299 } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/77/caa875a1f5a8a8980b564cc0e6fee1bc992d62d29101252561d0a5e9719c/SQLAlchemy-2.0.38-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ecef029b69843b82048c5b347d8e6049356aa24ed644006c9a9d7098c3bd3bfd", size = 2100201, upload-time = "2025-02-06T22:18:00.802Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ec/94bb036ec78bf9a20f8010c807105da9152dd84f72e8c51681ad2f30b3fd/SQLAlchemy-2.0.38-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c8bcad7fc12f0cc5896d8e10fdf703c45bd487294a986903fe032c72201596b", size = 2090678, upload-time = "2025-02-06T22:18:02.923Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/63ff1893f146e34d3934c0860209fdd3925c25ee064330e6c2152bacc335/SQLAlchemy-2.0.38-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a0ef3f98175d77180ffdc623d38e9f1736e8d86b6ba70bff182a7e68bed7727", size = 3177107, upload-time = "2025-02-06T21:07:31.027Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4f/b933bea41a602b5f274065cc824fae25780ed38664d735575192490a021b/SQLAlchemy-2.0.38-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b0ac78898c50e2574e9f938d2e5caa8fe187d7a5b69b65faa1ea4648925b096", size = 3190435, upload-time = "2025-02-06T22:19:29.458Z" }, - { url = "https://files.pythonhosted.org/packages/f5/23/9e654b4059e385988de08c5d3b38a369ea042f4c4d7c8902376fd737096a/SQLAlchemy-2.0.38-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9eb4fa13c8c7a2404b6a8e3772c17a55b1ba18bc711e25e4d6c0c9f5f541b02a", size = 3123648, upload-time = "2025-02-06T21:07:32.591Z" }, - { url = "https://files.pythonhosted.org/packages/83/59/94c6d804e76ebc6412a08d2b086a8cb3e5a056cd61508e18ddaf3ec70100/SQLAlchemy-2.0.38-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dba1cdb8f319084f5b00d41207b2079822aa8d6a4667c0f369fce85e34b0c86", size = 3151789, upload-time = "2025-02-06T22:19:32.523Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/17f143013aabbe1256dce19061eafdce0b0142465ce32168cdb9a18c04b1/SQLAlchemy-2.0.38-cp313-cp313-win32.whl", hash = "sha256:eae27ad7580529a427cfdd52c87abb2dfb15ce2b7a3e0fc29fbb63e2ed6f8120", size = 2073023, upload-time = "2025-02-06T20:25:32.861Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/259404b03c3ed2e7eee4c179e001a07d9b61070334be91124cf4ad32eec7/SQLAlchemy-2.0.38-cp313-cp313-win_amd64.whl", hash = "sha256:b335a7c958bc945e10c522c069cd6e5804f4ff20f9a744dd38e748eb602cbbda", size = 2096908, upload-time = "2025-02-06T20:25:35.053Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e4/592120713a314621c692211eba034d09becaf6bc8848fabc1dc2a54d8c16/SQLAlchemy-2.0.38-py3-none-any.whl", hash = "sha256:63178c675d4c80def39f1febd625a6333f44c0ba269edd8a468b156394b27753", size = 1896347, upload-time = "2025-02-06T22:08:29.784Z" }, + { url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887 }, + { url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367 }, + { url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806 }, + { url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131 }, + { url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364 }, + { url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482 }, + { url = "https://files.pythonhosted.org/packages/33/ac/e5e0a807163652a35be878c0ad5cfd8b1d29605edcadfb5df3c512cdf9f3/sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500", size = 2080704 }, + { url = "https://files.pythonhosted.org/packages/1c/cb/f38c61f7f2fd4d10494c1c135ff6a6ddb63508d0b47bccccd93670637309/sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad", size = 2104564 }, + { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894 }, ] [[package]] @@ -1221,29 +1221,29 @@ wheels = [ [[package]] name = "types-psutil" -version = "7.0.0.20250218" +version = "7.0.0.20250401" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/7c/145600d30456e7ccbb499abcf718aab2bd830e604a0ae8eb32b67cd346a6/types_psutil-7.0.0.20250218.tar.gz", hash = "sha256:1e642cdafe837b240295b23b1cbd4691d80b08a07d29932143cbbae30eb0db9c", size = 19828, upload-time = "2025-02-18T02:40:23.212Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/fc/3829cb113aa05c268b18369f1f003a4589216931658ebfa69e3d4931ba60/types_psutil-7.0.0.20250401.tar.gz", hash = "sha256:2a7d663c0888a079fc1643ebc109ad12e57a21c9552a9e2035da504191336dbf", size = 20273 } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/c8/f4365293408da4a9bcb1849d3efd8c60427cffff68cbb98ab1b81851d8bb/types_psutil-7.0.0.20250218-py3-none-any.whl", hash = "sha256:1447a30c282aafefcf8941ece854e1100eee7b0296a9d9be9977292f0269b121", size = 22763, upload-time = "2025-02-18T02:40:21.454Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/45e01f3bce242c0caad36b968114a00f454169df6c771c092c96727239d8/types_psutil-7.0.0.20250401-py3-none-any.whl", hash = "sha256:ed23f7140368104afe4e05a6085a5fa56fbe8c880a0f4dfe8d63e041106071ed", size = 23173 }, ] [[package]] name = "types-pyyaml" -version = "6.0.12.20241230" +version = "6.0.12.20250402" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078, upload-time = "2024-12-30T02:44:38.168Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/68/609eed7402f87c9874af39d35942744e39646d1ea9011765ec87b01b2a3c/types_pyyaml-6.0.12.20250402.tar.gz", hash = "sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075", size = 17282 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029, upload-time = "2024-12-30T02:44:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/ed/56/1fe61db05685fbb512c07ea9323f06ea727125951f1eb4dff110b3311da3/types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681", size = 20329 }, ] [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.13.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, ] [[package]]