diff --git a/app/models.py b/app/models.py index 1c9499a..f6304e7 100644 --- a/app/models.py +++ b/app/models.py @@ -1,12 +1,11 @@ #!/usr/bin/python3 -import dbconfig import os.path import re +from dbconfig import Session from datetime import datetime -from dbconfig import engine from typing import List, Optional from pydub import AudioSegment @@ -40,10 +39,8 @@ from helpers import ( ) from log import DEBUG, ERROR -Session = dbconfig.session Base: DeclarativeMeta = declarative_base() -Base.metadata.create_all(engine) # Database classes @@ -70,7 +67,7 @@ class NoteColours(Base): self.order = order session.add(self) - session.commit() + session.flush() def __repr__(self) -> str: return ( @@ -142,7 +139,7 @@ class Notes(Base): self.row = row self.note = text session.add(self) - session.commit() + session.flush() def __repr__(self) -> str: return ( @@ -155,7 +152,7 @@ class Notes(Base): DEBUG(f"delete_note({self.id=}") session.query(Notes).filter_by(id=self.id).delete() - session.commit() + session.flush() @classmethod def get_by_id(cls, session: Session, note_id: int) -> Optional["Notes"]: @@ -181,7 +178,7 @@ class Notes(Base): self.row = row if text: self.note = text - session.commit() + session.flush() class Playdates(Base): @@ -202,7 +199,7 @@ class Playdates(Base): self.track_id = track.id track.update_lastplayed(session) session.add(self) - session.commit() + session.flush() @staticmethod def last_played(session: Session, track_id: int) -> Optional[datetime]: @@ -224,9 +221,8 @@ class Playdates(Base): """ session.query(Playdates).filter( - Playdates.track_id == track_id, - ).delete() - session.commit() + Playdates.track_id == track_id).delete() + session.flush() class Playlists(Base): @@ -251,7 +247,7 @@ class Playlists(Base): def __init__(self, session: Session, name: str) -> None: self.name = name session.add(self) - session.commit() + session.flush() def __repr__(self) -> str: return f"" @@ -279,7 +275,7 @@ class Playlists(Base): self.loaded = False session.add(self) - session.commit() + session.flush() @classmethod def get_all(cls, session: Session) -> List["Playlists"]: @@ -320,25 +316,38 @@ class Playlists(Base): self.loaded = True self.last_used = datetime.now() - session.commit() + session.flush() + + def move_track(self, session: Session, rows: List[int], + to_playlist: "Playlists") -> None: + """Move tracks to another playlist""" + + for row in rows: + track = self.tracks[row] + to_playlist.add_track(session, track.id) + del self.tracks[row] + + session.flush() def remove_all_tracks(self, session: Session) -> None: """ Remove all tracks from this playlist """ - session.query(PlaylistTracks).filter( - PlaylistTracks.playlist_id == self.id, - ).delete() - session.commit() + self.tracks = {} + session.flush() def remove_track(self, session: Session, row: int) -> None: DEBUG(f"Playlist.remove_track({self.id=}, {row=})") - session.query(PlaylistTracks).filter( - PlaylistTracks.playlist_id == self.id, - PlaylistTracks.row == row - ).delete() + # Get tracks collection for this playlist + tracks_collections = self.tracks + # Tracks are a dictionary of tracks keyed on row + # number. Remove the relevant row. + del tracks_collections[row] + # Save the new tracks collection + self.tracks = tracks_collections + session.flush() class PlaylistTracks(Base): @@ -355,7 +364,8 @@ class PlaylistTracks(Base): backref=backref( "playlist_tracks", collection_class=attribute_mapped_collection("row"), - lazy="joined" + lazy="joined", + cascade="all, delete-orphan" ) ) @@ -368,45 +378,7 @@ class PlaylistTracks(Base): self.track_id = track_id self.row = row session.add(self) - session.commit() - - @staticmethod - def move_track( - session: Session, from_playlist_id: int, row: int, - to_playlist_id: int) -> None: - """ - Move track between playlists. This would be more efficient with - an ORM-enabled UPDATE statement, but this works just fine. - """ - DEBUG( - "PlaylistTracks.move_tracks(" - f"{from_playlist_id=}, {row=}, {to_playlist_id=})" - ) - - new_row: int - max_row: Optional[int] = session.query( - func.max(PlaylistTracks.row)).filter( - PlaylistTracks.playlist_id == to_playlist_id).scalar() - if max_row is None: - # Destination playlist is empty; use row 0 - new_row = 0 - else: - # Destination playlist has tracks; add to end - new_row = max_row + 1 - try: - record: PlaylistTracks = session.query(PlaylistTracks).filter( - PlaylistTracks.playlist_id == from_playlist_id, - PlaylistTracks.row == row).one() - except NoResultFound: - ERROR( - f"No rows matched in query: " - f"PlaylistTracks.playlist_id == {from_playlist_id}, " - f"PlaylistTracks.row == {row}" - ) - return - record.playlist_id = to_playlist_id - record.row = new_row - session.commit() + session.flush() @staticmethod def next_free_row(session: Session, playlist: Playlists) -> int: @@ -449,14 +421,14 @@ class Settings(Base): int_setting.name = name int_setting.f_int = None session.add(int_setting) - session.commit() + session.flush() return int_setting def update(self, session: Session, data): for key, value in data.items(): assert hasattr(self, key) setattr(self, key, value) - session.commit() + session.flush() class Tracks(Base): @@ -479,10 +451,30 @@ class Tracks(Base): back_populates="tracks", lazy="joined") - def __init__(self, session: Session, path: str) -> None: + def __init__(self, + session: Session, + path: str, + title: Optional[str] = None, + artist: Optional[str] = None, + duration: Optional[int] = None, + start_gap: Optional[int] = None, + fade_at: Optional[int] = None, + silence_at: Optional[int] = None, + mtime: Optional[float] = None, + lastplayed: Optional[datetime] = None, + ) -> None: self.path = path + self.title = title + self.artist = artist + self.duration = duration + self.start_gap = start_gap + self.fade_at = fade_at + self.silence_at = silence_at + self.mtime = mtime + self.lastplayed = lastplayed + session.add(self) - session.commit() + session.flush() def __repr__(self) -> str: return ( @@ -571,7 +563,7 @@ class Tracks(Base): Config.MILLISECOND_SIGFIGS) * 1000 self.start_gap = leading_silence(audio) session.add(self) - session.commit() + session.flush() @staticmethod def remove_by_path(session: Session, path: str) -> None: @@ -581,7 +573,7 @@ class Tracks(Base): try: session.query(Tracks).filter(Tracks.path == path).delete() - session.commit() + session.flush() except IntegrityError as exception: ERROR(f"Can't remove track with {path=} ({exception=})") @@ -605,17 +597,17 @@ class Tracks(Base): def update_lastplayed(self, session: Session) -> None: self.lastplayed = datetime.now() session.add(self) - session.commit() + session.flush() def update_artist(self, session: Session, artist: str) -> None: self.artist = artist session.add(self) - session.commit() + session.flush() def update_title(self, session: Session, title: str) -> None: self.title = title session.add(self) - session.commit() + session.flush() def update_path(self, newpath: str) -> None: self.path = newpath diff --git a/app/musicmuster.py b/app/musicmuster.py index 653e86c..2ebe1e8 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -23,6 +23,7 @@ from PyQt5.QtWidgets import ( QMainWindow, ) +import dbconfig import helpers import music @@ -979,15 +980,13 @@ class SelectPlaylistDialog(QDialog): self.accept() -def main(): +if __name__ == "__main__": try: + Base.metadata.create_all(dbconfig.engine) + Session = dbconfig.Session app = QApplication(sys.argv) win = Window() win.show() sys.exit(app.exec()) except Exception: EXCEPTION("Unhandled Exception caught by musicmuster.main()") - - -if __name__ == "__main__": - main() diff --git a/app/playlists.py b/app/playlists.py index 2a80c44..c802ca6 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -27,11 +27,11 @@ from models import ( Notes, Playdates, Playlists, - Session, Settings, Tracks, NoteColours ) +from dbconfig import Session class RowMeta: @@ -101,7 +101,7 @@ class PlaylistTab(QTableWidget): self.setHorizontalHeaderItem(7, item) self.horizontalHeader().setMinimumSectionSize(0) - self._set_column_widths() + self._set_column_widths(session) self.setHorizontalHeaderLabels([ Config.COLUMN_NAME_AUTOPLAY, Config.COLUMN_NAME_LEADING_SILENCE, @@ -1368,7 +1368,7 @@ class PlaylistTab(QTableWidget): ms: int = 0 with Session() as session: for row in (sel_rows - notes_rows): - ms += self._get_row_track_object(row, session).duration + ms += self._get_row_track_object(row, session).duration or 0 # Only paint message if there are selected track rows if ms > 0: @@ -1398,17 +1398,16 @@ class PlaylistTab(QTableWidget): # Reset extended selection self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - def _set_column_widths(self) -> None: + def _set_column_widths(self, session: Session) -> None: """Column widths from settings""" - with Session() as session: - for column in range(self.columnCount()): - name: str = f"playlist_col_{str(column)}_width" - record: Settings = Settings.get_int_settings(session, name) - if record and record.f_int is not None: - self.setColumnWidth(column, record.f_int) - else: - self.setColumnWidth(column, Config.DEFAULT_COLUMN_WIDTH) + for column in range(self.columnCount()): + name: str = f"playlist_col_{str(column)}_width" + record: Settings = Settings.get_int_settings(session, name) + if record and record.f_int is not None: + self.setColumnWidth(column, record.f_int) + else: + self.setColumnWidth(column, Config.DEFAULT_COLUMN_WIDTH) def _set_next(self, row: int, session: Session) -> None: """ diff --git a/app/utilities.py b/app/utilities.py index 17bf05e..4459710 100755 --- a/app/utilities.py +++ b/app/utilities.py @@ -53,7 +53,7 @@ def main(): DEBUG("Finished") -def create_track_from_file(session, path, interactive=False): +def create_track_from_file(session, path, normalise=None, interactive=False): """ Create track in database from passed path, or update database entry if path already in database. @@ -100,7 +100,7 @@ def create_track_from_file(session, path, interactive=False): track.mtime = os.path.getmtime(path) session.commit() - if Config.NORMALISE_ON_IMPORT: + if normalise or normalise is None and Config.NORMALISE_ON_IMPORT: if interactive: INFO("Normalise...") # Check type diff --git a/conftest.py b/conftest.py index 943aaa9..db28026 100644 --- a/conftest.py +++ b/conftest.py @@ -2,11 +2,12 @@ import pytest import sys +sys.path.append("app") +import models from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session, sessionmaker -sys.path.append("app") @pytest.fixture(scope="session") @@ -18,23 +19,6 @@ def connection(): return engine.connect() -def seed_database(): - pass - - # users = [ - # { - # "id": 1, - # "name": "John Doe", - # }, - # # ... - # ] - - # for user in users: - # db_user = User(**user) - # db_session.add(db_user) - # db_session.commit() - - @pytest.fixture(scope="session") def setup_database(connection): from app.models import Base # noqa E402 diff --git a/poetry.lock b/poetry.lock index 810d8de..6c3b00c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -943,6 +943,12 @@ pytest = [ {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, ] +pytest-profiling = [ + {file = "pytest-profiling-1.7.0.tar.gz", hash = "sha256:93938f147662225d2b8bd5af89587b979652426a8a6ffd7e73ec4a23e24b7f29"}, + {file = "pytest_profiling-1.7.0-py2.7.egg", hash = "sha256:3b255f9db36cb2dd7536a8e7e294c612c0be7f7850a7d30754878e4315d56600"}, + {file = "pytest_profiling-1.7.0-py2.py3-none-any.whl", hash = "sha256:999cc9ac94f2e528e3f5d43465da277429984a1c237ae9818f8cfd0b06acb019"}, + {file = "pytest_profiling-1.7.0-py3.6.egg", hash = "sha256:6bce4e2edc04409d2f3158c16750fab8074f62d404cc38eeb075dff7fcbb996c"}, +] pytest-qt = [ {file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"}, {file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"}, diff --git a/test_models.py b/test_models.py index 035afd9..8ab5769 100644 --- a/test_models.py +++ b/test_models.py @@ -126,7 +126,7 @@ def test_playdates_add_playdate(session): assert playdate last_played = Playdates.last_played(session, track.id) - assert playdate.lastplayed == last_played + assert abs((playdate.lastplayed - last_played).total_seconds()) < 2 def test_playdates_remove_track(session): @@ -274,6 +274,9 @@ def test_playlist_remove_tracks(session): playlist1.remove_track(session, 1) assert len(playlist1.tracks) == 2 + # Check the track itself still exists + original_track = Tracks.get_by_id(session, track1.id) + assert original_track playlist1.remove_all_tracks(session) assert len(playlist1.tracks) == 0 @@ -330,8 +333,7 @@ def test_playlisttracks_move_track(session): assert tracks[track2_row] == track2 # Move track2 to playlist2 and check - PlaylistTracks.move_track( - session, playlist1.id, track2_row, playlist2.id) + playlist1.move_track(session, [track2_row], playlist2) tracks1 = playlist1.tracks tracks2 = playlist2.tracks diff --git a/test_playlists.py b/test_playlists.py index cf287e0..8866227 100644 --- a/test_playlists.py +++ b/test_playlists.py @@ -1,15 +1,47 @@ from PyQt5.QtCore import Qt -from app.playlists import Notes, PlaylistTab, Tracks -from app.models import Playlists -import musicmuster +from app import playlists +from app import models +from app import musicmuster +from app import dbconfig + + +def seed2tracks(session): + tracks = [ + { + "path": "testdata/isa.mp3", + "title": "I'm so afraid", + "artist": "Fleetwood Mac", + "duration": 263000, + "start_gap": 60, + "fade_at": 236263, + "silence_at": 260343, + "mtime": 371900000, + }, + { + "path": "testdata/mom.mp3", + "title": "Man of Mystery", + "artist": "The Shadows", + "duration": 120000, + "start_gap": 70, + "fade_at": 115000, + "silence_at": 118000, + "mtime": 1642760000, + }, + ] + + for track in tracks: + db_track = models.Tracks(session=session, **track) + session.add(db_track) + + session.commit() def test_init(qtbot, session): """Just check we can create a playlist_tab""" - playlist = Playlists(session, "my playlist") - playlist_tab = PlaylistTab(None, session, playlist.id) + playlist = models.Playlists(session, "my playlist") + playlist_tab = playlists.PlaylistTab(None, session, playlist.id) assert playlist_tab @@ -17,46 +49,51 @@ def test_save_and_restore(qtbot, session): """Playlist with one track, one note, save and restore""" # Create playlist - playlist = Playlists(session, "my playlist") - playlist_tab = PlaylistTab(None, session, playlist.id) + playlist = models.Playlists(session, "my playlist") + playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # Insert a note note_text = "my note" note_row = 7 - note = Notes(session, playlist.id, note_row, note_text) + note = models.Notes(session, playlist.id, note_row, note_text) playlist_tab._insert_note(session, note) # Add a track track_path = "/a/b/c" - track = Tracks(session, track_path) + track = models.Tracks(session, track_path) playlist_tab.insert_track(session, track) # Save playlist playlist_tab.save_playlist(session) + # We need to commit the session before re-querying + session.commit() + # Retrieve playlist - playlists = Playlists.get_open(session) - assert len(playlists) == 1 - retrieved_playlist = playlists[0] - assert track_path in [a.path for a in retrieved_playlist.tracks.values()] - assert note_text in [a.note for a in retrieved_playlist.notes] + all_playlists = playlists.Playlists.get_open(session) + assert len(all_playlists) == 1 + retrieved_playlist = all_playlists[0] + paths = [a.path for a in retrieved_playlist.tracks.values()] + assert track_path in paths + notes = [a.note for a in retrieved_playlist.notes] + assert note_text in notes def test_meta_all_clear(qtbot, session): # Create playlist - playlist = Playlists(session, "my playlist") - playlist_tab = PlaylistTab(None, session, playlist.id) + playlist = models.Playlists(session, "my playlist") + playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # Add some tracks track1_path = "/a/b/c" - track1 = Tracks(session, track1_path) + track1 = models.Tracks(session, track1_path) playlist_tab.insert_track(session, track1) track2_path = "/d/e/f" - track2 = Tracks(session, track2_path) + track2 = models.Tracks(session, track2_path) playlist_tab.insert_track(session, track2) track3_path = "/h/i/j" - track3 = Tracks(session, track3_path) + track3 = models.Tracks(session, track3_path) playlist_tab.insert_track(session, track3) assert playlist_tab._get_current_track_row() is None @@ -69,18 +106,18 @@ def test_meta_all_clear(qtbot, session): def test_meta(qtbot, session): # Create playlist - playlist = Playlists(session, "my playlist") - playlist_tab = PlaylistTab(None, session, playlist.id) + playlist = playlists.Playlists(session, "my playlist") + playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # Add some tracks track1_path = "/a/b/c" - track1 = Tracks(session, track1_path) + track1 = models.Tracks(session, track1_path) playlist_tab.insert_track(session, track1) track2_path = "/d/e/f" - track2 = Tracks(session, track2_path) + track2 = models.Tracks(session, track2_path) playlist_tab.insert_track(session, track2) track3_path = "/h/i/j" - track3 = Tracks(session, track3_path) + track3 = models.Tracks(session, track3_path) playlist_tab.insert_track(session, track3) assert len(playlist_tab._get_unreadable_track_rows()) == 3 @@ -99,7 +136,7 @@ def test_meta(qtbot, session): # Add a note note_text = "my note" note_row = 7 # will be added as row 3 - note = Notes(session, playlist.id, note_row, note_text) + note = models.Notes(session, playlist.id, note_row, note_text) playlist_tab._insert_note(session, note) assert playlist_tab._get_played_track_rows() == [0] @@ -147,15 +184,15 @@ def test_meta(qtbot, session): def test_clear_next(qtbot, session): # Create playlist - playlist = Playlists(session, "my playlist") - playlist_tab = PlaylistTab(None, session, playlist.id) + playlist = models.Playlists(session, "my playlist") + playlist_tab = playlists.PlaylistTab(None, session, playlist.id) # Add some tracks track1_path = "/a/b/c" - track1 = Tracks(session, track1_path) + track1 = models.Tracks(session, track1_path) playlist_tab.insert_track(session, track1) track2_path = "/d/e/f" - track2 = Tracks(session, track2_path) + track2 = models.Tracks(session, track2_path) playlist_tab.insert_track(session, track2) playlist_tab._set_next_track_row(1) @@ -165,21 +202,24 @@ def test_clear_next(qtbot, session): assert playlist_tab._get_next_track_row() is None -def test_get_selected_row(qtbot, session): +def test_get_selected_row(qtbot, monkeypatch, session): - # Create playlist - playlist = Playlists(session, "test playlist") - playlist_tab = PlaylistTab(None, session, playlist.id) + monkeypatch.setattr(musicmuster, "Session", session) + monkeypatch.setattr(playlists, "Session", session) + + # Create playlist and playlist_tab + window = musicmuster.Window() + playlist = models.Playlists(session, "test playlist") + playlist_tab = playlists.PlaylistTab(window, session, playlist.id) # Add some tracks track1_path = "/a/b/c" - track1 = Tracks(session, track1_path) + track1 = models.Tracks(session, track1_path) playlist_tab.insert_track(session, track1) track2_path = "/d/e/f" - track2 = Tracks(session, track2_path) + track2 = models.Tracks(session, track2_path) playlist_tab.insert_track(session, track2) - window = Window() qtbot.addWidget(playlist_tab) with qtbot.waitExposed(window): window.show() @@ -191,31 +231,37 @@ def test_get_selected_row(qtbot, session): ) -def test_set_next(qtbot, session): +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 - playlist = Playlists(session, "test playlist") - playlist_tab = PlaylistTab(None, session, playlist.id) + window = musicmuster.Window() + playlist = models.Playlists(session, playlist_name) + 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_path = "testdata/isa.mp3" - track1 = Tracks(session, track1_path) + track1 = models.Tracks.get_from_filename(session, "isa.mp3") playlist_tab.insert_track(session, track1) - track2_path = "mom.mp3" - track2 = Tracks(session, track2_path) + track2 = models.Tracks.get_from_filename(session, "mom.mp3") playlist_tab.insert_track(session, track2) - window = Window() - qtbot.addWidget(playlist_tab) 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() ) - qtbot.wait(10000) + # qtbot.wait(10000) qtbot.keyPress(playlist_tab.viewport(), "N", modifier=Qt.ControlModifier) qtbot.wait(2000) @@ -223,6 +269,12 @@ def test_set_next(qtbot, session): 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) +