diff --git a/app/models.py b/app/models.py index bd04296..bb5dd6f 100644 --- a/app/models.py +++ b/app/models.py @@ -17,9 +17,10 @@ from sqlalchemy import ( DateTime, Float, ForeignKey, + func, Integer, String, - func + UniqueConstraint, ) from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import ( @@ -39,7 +40,6 @@ from helpers import ( ) from log import DEBUG, ERROR - Base: DeclarativeMeta = declarative_base() @@ -97,9 +97,9 @@ class NoteColours(Base): for rec in ( session.query(NoteColours) - .filter(NoteColours.enabled.is_(True)) - .order_by(NoteColours.order) - .all() + .filter(NoteColours.enabled.is_(True)) + .order_by(NoteColours.order) + .all() ): if rec.is_regex: flags = re.UNICODE @@ -206,8 +206,8 @@ class Playdates(Base): last_played: Optional[Playdates] = session.query( Playdates.lastplayed).filter( - (Playdates.track_id == track_id) - ).order_by(Playdates.lastplayed.desc()).first() + (Playdates.track_id == track_id) + ).order_by(Playdates.lastplayed.desc()).first() if last_played: return last_played[0] else: @@ -265,7 +265,7 @@ class Playlists(Base): """ if not row: - row = PlaylistTracks.next_free_row(session, self) + row = PlaylistTracks.next_free_row(session, self.id) PlaylistTracks(session, self.id, track_id, row) @@ -294,8 +294,8 @@ class Playlists(Base): return ( session.query(cls) - .filter(cls.loaded.is_(False)) - .order_by(cls.last_used.desc()) + .filter(cls.loaded.is_(False)) + .order_by(cls.last_used.desc()) ).all() @classmethod @@ -306,8 +306,8 @@ class Playlists(Base): return ( session.query(cls) - .filter(cls.loaded.is_(True)) - .order_by(cls.last_used.desc()) + .filter(cls.loaded.is_(True)) + .order_by(cls.last_used.desc()) ).all() def mark_open(self, session: Session) -> None: @@ -317,8 +317,9 @@ class Playlists(Base): self.last_used = datetime.now() session.flush() - def move_track(self, session: Session, rows: List[int], - to_playlist: "Playlists") -> None: + def move_track( + self, session: Session, rows: List[int], + to_playlist: "Playlists") -> None: """Move tracks to another playlist""" for row in rows: @@ -354,7 +355,7 @@ class PlaylistTracks(Base): id: int = Column(Integer, primary_key=True, autoincrement=True) playlist_id: int = Column(Integer, ForeignKey('playlists.id'), - primary_key=True) + primary_key=True) track_id: int = Column(Integer, ForeignKey('tracks.id'), primary_key=True) row: int = Column(Integer, nullable=False) tracks: RelationshipProperty = relationship("Tracks") @@ -367,6 +368,10 @@ class PlaylistTracks(Base): cascade="all, delete-orphan" ) ) + # Ensure row numbers are unique within each playlist + __table_args__ = (UniqueConstraint + ('row', 'playlist_id', name="uniquerow"), + ) def __init__( self, session: Session, playlist_id: int, track_id: int, @@ -380,14 +385,14 @@ class PlaylistTracks(Base): session.flush() @staticmethod - def next_free_row(session: Session, playlist: Playlists) -> int: + def next_free_row(session: Session, playlist_id: int) -> int: """Return next free row number""" row: int last_row = session.query( func.max(PlaylistTracks.row) - ).filter_by(playlist_id=playlist.id).first() + ).filter_by(playlist_id=playlist_id).first() # if there are no rows, the above returns (None, ) which is True if last_row and last_row[0] is not None: row = last_row[0] + 1 @@ -396,6 +401,33 @@ class PlaylistTracks(Base): return row + @staticmethod + def move_rows( + session: Session, rows: List[int], from_playlist_id: int, + to_playlist_id: int) -> None: + """Move rows between playlists""" + + # A constraint deliberately blocks duplicate (playlist_id, row) + # entries in database; however, unallocated rows in the database + # are fine (ie, we can have rows 1, 4, 6 and no 2, 3, 5). + # Unallocated rows will be automatically removed when the + # playlist is saved. + + lowest_source_row: int = min(rows) + first_destination_free_row = PlaylistTracks.next_free_row( + session, to_playlist_id) + # Calculate offset that will put the lowest row number being + # moved at the first free row in destination playlist + offset = first_destination_free_row - lowest_source_row + + session.query(PlaylistTracks).filter( + PlaylistTracks.playlist_id == from_playlist_id, + PlaylistTracks.row.in_(rows) + ).update({'playlist_id': to_playlist_id, + 'row': PlaylistTracks.row + offset}, + False + ) + class Settings(Base): __tablename__ = 'settings' @@ -444,24 +476,25 @@ class Tracks(Base): mtime: float = Column(Float, index=True) lastplayed: datetime = Column(DateTime, index=True, default=None) playlists: RelationshipProperty = relationship("PlaylistTracks", - back_populates="tracks", - lazy="joined") + back_populates="tracks", + lazy="joined") playdates: RelationshipProperty = relationship("Playdates", - back_populates="tracks", - lazy="joined") + back_populates="tracks", + lazy="joined") - 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: + 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 @@ -556,10 +589,10 @@ class Tracks(Base): audio: AudioSegment = get_audio_segment(self.path) self.duration = len(audio) self.fade_at = round(fade_point(audio) / 1000, - Config.MILLISECOND_SIGFIGS) * 1000 + Config.MILLISECOND_SIGFIGS) * 1000 self.mtime = os.path.getmtime(self.path) self.silence_at = round(trailing_silence(audio) / 1000, - Config.MILLISECOND_SIGFIGS) * 1000 + Config.MILLISECOND_SIGFIGS) * 1000 self.start_gap = leading_silence(audio) session.add(self) session.flush() @@ -581,16 +614,16 @@ class Tracks(Base): return ( session.query(cls) - .filter(cls.artist.ilike(f"%{text}%")) - .order_by(cls.title) + .filter(cls.artist.ilike(f"%{text}%")) + .order_by(cls.title) ).all() @classmethod def search_titles(cls, session: Session, text: str) -> List["Tracks"]: return ( session.query(cls) - .filter(cls.title.ilike(f"%{text}%")) - .order_by(cls.title) + .filter(cls.title.ilike(f"%{text}%")) + .order_by(cls.title) ).all() @staticmethod diff --git a/app/musicmuster.py b/app/musicmuster.py index 4165239..d81e39e 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -424,7 +424,8 @@ class Window(QMainWindow, Ui_MainWindow): # Update database for both source and destination playlists rows = visible_tab.get_selected_rows() - source_playlist.move_track(session, rows, destination_playlist) + PlaylistTracks.move_rows(session, rows, source_playlist.id, + destination_playlist.id) # Update destination playlist_tab if visible (if not visible, it # will be re-populated when it is opened) @@ -594,10 +595,10 @@ class Window(QMainWindow, Ui_MainWindow): dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) dlg.exec() - if dlg.plid: - p = Playlists.get_by_id(session=session, playlist_id=dlg.plid) - p.mark_open(session) - self.create_playlist_tab(session, p) + playlist = dlg.playlist + if playlist: + playlist.mark_open(session) + self.create_playlist_tab(session, playlist) def select_next_row(self) -> None: """Select next or first row in playlist""" @@ -971,11 +972,6 @@ class SelectPlaylistDialog(QDialog): height = record.f_int or 600 self.resize(width, height) - # for (plid, plname) in [(a.id, a.name) for a in playlists]: - # p = QListWidgetItem() - # p.setText(plname) - # p.setData(Qt.UserRole, plid) - # self.ui.lstPlaylists.addItem(p) for playlist in playlists: p = QListWidgetItem() p.setText(playlist.name)