Compare commits
No commits in common. "c01b47322f7073e10dd7dc3c5fcf0bd4710ae3a8" and "c12b30a956396038381ca5b7294aaa6ca30c70b8" have entirely different histories.
c01b47322f
...
c12b30a956
@ -14,11 +14,6 @@ from PyQt6.QtCore import (
|
|||||||
pyqtSignal,
|
pyqtSignal,
|
||||||
QObject,
|
QObject,
|
||||||
)
|
)
|
||||||
from PyQt6.QtWidgets import (
|
|
||||||
QProxyStyle,
|
|
||||||
QStyle,
|
|
||||||
QStyleOption,
|
|
||||||
)
|
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
|
|
||||||
@ -36,14 +31,6 @@ class Col(Enum):
|
|||||||
NOTE = auto()
|
NOTE = auto()
|
||||||
|
|
||||||
|
|
||||||
class QueryCol(Enum):
|
|
||||||
TITLE = 0
|
|
||||||
ARTIST = auto()
|
|
||||||
DURATION = auto()
|
|
||||||
LAST_PLAYED = auto()
|
|
||||||
BITRATE = auto()
|
|
||||||
|
|
||||||
|
|
||||||
def singleton(cls):
|
def singleton(cls):
|
||||||
"""
|
"""
|
||||||
Make a class a Singleton class (see
|
Make a class a Singleton class (see
|
||||||
@ -113,24 +100,6 @@ class MusicMusterSignals(QObject):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
|
||||||
class PlaylistStyle(QProxyStyle):
|
|
||||||
def drawPrimitive(self, element, option, painter, widget=None):
|
|
||||||
"""
|
|
||||||
Draw a line across the entire row rather than just the column
|
|
||||||
we're hovering over.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
|
|
||||||
and not option.rect.isNull()
|
|
||||||
):
|
|
||||||
option_new = QStyleOption(option)
|
|
||||||
option_new.rect.setLeft(0)
|
|
||||||
if widget:
|
|
||||||
option_new.rect.setRight(widget.width())
|
|
||||||
option = option_new
|
|
||||||
super().drawPrimitive(element, option, painter, widget)
|
|
||||||
|
|
||||||
|
|
||||||
class Tags(NamedTuple):
|
class Tags(NamedTuple):
|
||||||
artist: str = ""
|
artist: str = ""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
|||||||
@ -48,11 +48,9 @@ class PlaydatesTable(Model):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
|
lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
|
||||||
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
|
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"))
|
||||||
track: Mapped["TracksTable"] = relationship(
|
track: Mapped["TracksTable"] = relationship(
|
||||||
"TracksTable",
|
"TracksTable", back_populates="playdates"
|
||||||
back_populates="playdates",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@ -81,9 +79,6 @@ class PlaylistsTable(Model):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
order_by="PlaylistRowsTable.row_number",
|
order_by="PlaylistRowsTable.row_number",
|
||||||
)
|
)
|
||||||
query: Mapped["QueriesTable"] = relationship(
|
|
||||||
back_populates="playlist", cascade="all, delete-orphan"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
@ -100,16 +95,12 @@ class PlaylistRowsTable(Model):
|
|||||||
note: Mapped[str] = mapped_column(
|
note: Mapped[str] = mapped_column(
|
||||||
String(2048), index=False, default="", nullable=False
|
String(2048), index=False, default="", nullable=False
|
||||||
)
|
)
|
||||||
playlist_id: Mapped[int] = mapped_column(
|
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"), index=True)
|
||||||
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
|
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
|
||||||
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id", ondelete="CASCADE"))
|
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
|
||||||
track: Mapped["TracksTable"] = relationship(
|
track: Mapped["TracksTable"] = relationship(
|
||||||
"TracksTable",
|
"TracksTable",
|
||||||
back_populates="playlistrows",
|
back_populates="playlistrows",
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
)
|
||||||
played: Mapped[bool] = mapped_column(
|
played: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, index=False, default=False
|
Boolean, nullable=False, index=False, default=False
|
||||||
@ -123,22 +114,6 @@ class PlaylistRowsTable(Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class QueriesTable(Model):
|
|
||||||
__tablename__ = "queries"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
||||||
query: Mapped[str] = mapped_column(
|
|
||||||
String(2048), index=False, default="", nullable=False
|
|
||||||
)
|
|
||||||
playlist_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
playlist: Mapped[PlaylistsTable] = relationship(back_populates="query")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Queries(id={self.id}, playlist={self.playlist}, query={self.query}>"
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsTable(Model):
|
class SettingsTable(Model):
|
||||||
"""Manage settings"""
|
"""Manage settings"""
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
@ -36,6 +36,7 @@ if DATABASE_URL is None:
|
|||||||
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
|
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
|
||||||
raise ValueError("Unit tests running on non-Sqlite database")
|
raise ValueError("Unit tests running on non-Sqlite database")
|
||||||
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
|
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
|
||||||
# Database classes
|
# Database classes
|
||||||
@ -235,23 +236,10 @@ class Playlists(dbtables.PlaylistsTable):
|
|||||||
|
|
||||||
return session.scalars(
|
return session.scalars(
|
||||||
select(cls)
|
select(cls)
|
||||||
.filter(
|
.filter(cls.is_template.is_(False))
|
||||||
cls.is_template.is_(False),
|
|
||||||
~cls.query.has()
|
|
||||||
)
|
|
||||||
.order_by(cls.last_used.desc())
|
.order_by(cls.last_used.desc())
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_all_queries(cls, session: Session) -> Sequence["Playlists"]:
|
|
||||||
"""Returns a list of all query lists ordered by name"""
|
|
||||||
|
|
||||||
return session.scalars(
|
|
||||||
select(cls)
|
|
||||||
.where(cls.query.has())
|
|
||||||
.order_by(cls.name)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
|
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
|
||||||
"""Returns a list of all templates ordered by name"""
|
"""Returns a list of all templates ordered by name"""
|
||||||
@ -269,7 +257,6 @@ class Playlists(dbtables.PlaylistsTable):
|
|||||||
.filter(
|
.filter(
|
||||||
cls.open.is_(False),
|
cls.open.is_(False),
|
||||||
cls.is_template.is_(False),
|
cls.is_template.is_(False),
|
||||||
~cls.query.has()
|
|
||||||
)
|
)
|
||||||
.order_by(cls.last_used.desc())
|
.order_by(cls.last_used.desc())
|
||||||
).all()
|
).all()
|
||||||
@ -281,13 +268,7 @@ class Playlists(dbtables.PlaylistsTable):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return session.scalars(
|
return session.scalars(
|
||||||
select(cls)
|
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
|
||||||
.where(
|
|
||||||
cls.open.is_(True),
|
|
||||||
~cls.query.has()
|
|
||||||
)
|
|
||||||
.order_by(cls.tab)
|
|
||||||
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
def mark_open(self) -> None:
|
def mark_open(self) -> None:
|
||||||
@ -331,26 +312,6 @@ class Playlists(dbtables.PlaylistsTable):
|
|||||||
PlaylistRows.copy_playlist(session, playlist_id, template.id)
|
PlaylistRows.copy_playlist(session, playlist_id, template.id)
|
||||||
|
|
||||||
|
|
||||||
class Queries(dbtables.QueriesTable):
|
|
||||||
def __init__(self, session: Session, playlist_id: int, query: str = "") -> None:
|
|
||||||
self.playlist_id = playlist_id
|
|
||||||
self.query = query
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_query(session: Session, playlist_id: int) -> str:
|
|
||||||
"""
|
|
||||||
Return query associated with playlist or null string if none
|
|
||||||
"""
|
|
||||||
|
|
||||||
return session.execute(
|
|
||||||
select(Queries.query).where(
|
|
||||||
Queries.playlist_id == playlist_id
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistRows(dbtables.PlaylistRowsTable):
|
class PlaylistRows(dbtables.PlaylistRowsTable):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -62,9 +62,7 @@ from log import log
|
|||||||
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
|
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
|
||||||
from music_manager import RowAndTrack, track_sequence
|
from music_manager import RowAndTrack, track_sequence
|
||||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||||
from querylistmodel import QuerylistModel, QuerylistProxyModel
|
|
||||||
from playlists import PlaylistTab
|
from playlists import PlaylistTab
|
||||||
from querylists import QuerylistTab
|
|
||||||
from ui import icons_rc # noqa F401
|
from ui import icons_rc # noqa F401
|
||||||
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
|
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
|
||||||
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
|
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
|
||||||
@ -581,7 +579,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
|
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
|
||||||
self.actionNewPlaylist.triggered.connect(self.new_playlist)
|
self.actionNewPlaylist.triggered.connect(self.new_playlist)
|
||||||
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
|
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
|
||||||
self.actionOpenQuerylist.triggered.connect(self.open_querylist)
|
|
||||||
self.actionPaste.triggered.connect(self.paste_rows)
|
self.actionPaste.triggered.connect(self.paste_rows)
|
||||||
self.actionPlay_next.triggered.connect(self.play_next)
|
self.actionPlay_next.triggered.connect(self.play_next)
|
||||||
self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
|
self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
|
||||||
@ -651,7 +648,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
def create_playlist_tab(self, playlist: Playlists) -> int:
|
def create_playlist_tab(self, playlist: Playlists) -> int:
|
||||||
"""
|
"""
|
||||||
Take the passed playlist, create a playlist tab and
|
Take the passed proxy model, create a playlist tab and
|
||||||
add tab to display. Return index number of tab.
|
add tab to display. Return index number of tab.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -670,27 +667,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
def create_querylist_tab(self, querylist: Playlists) -> int:
|
|
||||||
"""
|
|
||||||
Take the passed querylist, create a querylist tab and
|
|
||||||
add tab to display. Return index number of tab.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"create_querylist_tab({querylist=})")
|
|
||||||
|
|
||||||
# Create model and proxy model
|
|
||||||
base_model = QuerylistModel(querylist.id)
|
|
||||||
proxy_model = QuerylistProxyModel()
|
|
||||||
proxy_model.setSourceModel(base_model)
|
|
||||||
|
|
||||||
# Create tab
|
|
||||||
querylist_tab = QuerylistTab(musicmuster=self, model=proxy_model)
|
|
||||||
idx = self.tabPlaylist.addTab(querylist_tab, querylist.name)
|
|
||||||
|
|
||||||
log.debug(f"create_querylist_tab() returned: {idx=}")
|
|
||||||
|
|
||||||
return idx
|
|
||||||
|
|
||||||
def current_row_or_end(self) -> int:
|
def current_row_or_end(self) -> int:
|
||||||
"""
|
"""
|
||||||
If a row or rows are selected, return the row number of the first
|
If a row or rows are selected, return the row number of the first
|
||||||
@ -1140,19 +1116,6 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
self.tabPlaylist.setCurrentIndex(idx)
|
self.tabPlaylist.setCurrentIndex(idx)
|
||||||
|
|
||||||
def open_querylist(self) -> None:
|
|
||||||
"""Open existing querylist"""
|
|
||||||
|
|
||||||
with db.Session() as session:
|
|
||||||
querylists = Playlists.get_all_queries(session)
|
|
||||||
dlg = SelectPlaylistDialog(self, playlists=querylists, session=session)
|
|
||||||
dlg.exec()
|
|
||||||
querylist = dlg.playlist
|
|
||||||
if querylist:
|
|
||||||
idx = self.create_querylist_tab(querylist)
|
|
||||||
|
|
||||||
self.tabPlaylist.setCurrentIndex(idx)
|
|
||||||
|
|
||||||
def open_songfacts_browser(self, title: str) -> None:
|
def open_songfacts_browser(self, title: str) -> None:
|
||||||
"""Search Songfacts for title"""
|
"""Search Songfacts for title"""
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,9 @@ from typing import Any, Callable, cast, Optional, TYPE_CHECKING
|
|||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
QAbstractItemModel,
|
QAbstractItemModel,
|
||||||
QEvent,
|
QEvent,
|
||||||
QItemSelection,
|
|
||||||
QModelIndex,
|
QModelIndex,
|
||||||
QObject,
|
QObject,
|
||||||
QPoint,
|
QItemSelection,
|
||||||
QSize,
|
QSize,
|
||||||
Qt,
|
Qt,
|
||||||
QTimer,
|
QTimer,
|
||||||
@ -37,7 +36,7 @@ from PyQt6.QtWidgets import (
|
|||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from audacity_controller import AudacityController
|
from audacity_controller import AudacityController
|
||||||
from classes import ApplicationError, QueryCol, MusicMusterSignals, PlaylistStyle, TrackInfo
|
from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo
|
||||||
from config import Config
|
from config import Config
|
||||||
from dialogs import TrackSelectDialog
|
from dialogs import TrackSelectDialog
|
||||||
from helpers import (
|
from helpers import (
|
||||||
@ -112,7 +111,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
|||||||
if self.current_editor:
|
if self.current_editor:
|
||||||
editor = self.current_editor
|
editor = self.current_editor
|
||||||
else:
|
else:
|
||||||
if index.column() == QueryCol.INTRO.value:
|
if index.column() == Col.INTRO.value:
|
||||||
editor = QDoubleSpinBox(parent)
|
editor = QDoubleSpinBox(parent)
|
||||||
editor.setDecimals(1)
|
editor.setDecimals(1)
|
||||||
editor.setSingleStep(0.1)
|
editor.setSingleStep(0.1)
|
||||||
@ -248,7 +247,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
|||||||
self.original_model_data = self.base_model.data(
|
self.original_model_data = self.base_model.data(
|
||||||
edit_index, Qt.ItemDataRole.EditRole
|
edit_index, Qt.ItemDataRole.EditRole
|
||||||
)
|
)
|
||||||
if index.column() == QueryCol.INTRO.value:
|
if index.column() == Col.INTRO.value:
|
||||||
if self.original_model_data.value():
|
if self.original_model_data.value():
|
||||||
editor.setValue(self.original_model_data.value() / 1000)
|
editor.setValue(self.original_model_data.value() / 1000)
|
||||||
else:
|
else:
|
||||||
@ -268,6 +267,24 @@ class PlaylistDelegate(QStyledItemDelegate):
|
|||||||
editor.setGeometry(option.rect)
|
editor.setGeometry(option.rect)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistStyle(QProxyStyle):
|
||||||
|
def drawPrimitive(self, element, option, painter, widget=None):
|
||||||
|
"""
|
||||||
|
Draw a line across the entire row rather than just the column
|
||||||
|
we're hovering over.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
|
||||||
|
and not option.rect.isNull()
|
||||||
|
):
|
||||||
|
option_new = QStyleOption(option)
|
||||||
|
option_new.rect.setLeft(0)
|
||||||
|
if widget:
|
||||||
|
option_new.rect.setRight(widget.width())
|
||||||
|
option = option_new
|
||||||
|
super().drawPrimitive(element, option, painter, widget)
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTab(QTableView):
|
class PlaylistTab(QTableView):
|
||||||
"""
|
"""
|
||||||
The playlist view
|
The playlist view
|
||||||
@ -821,28 +838,12 @@ class PlaylistTab(QTableView):
|
|||||||
log.debug(f"get_selected_rows() returned: {result=}")
|
log.debug(f"get_selected_rows() returned: {result=}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_top_visible_row(self) -> int:
|
|
||||||
"""
|
|
||||||
Get the viewport of the table view
|
|
||||||
"""
|
|
||||||
|
|
||||||
index = self.indexAt(QPoint(0, 0))
|
|
||||||
|
|
||||||
if index.isValid():
|
|
||||||
return index.row()
|
|
||||||
else:
|
|
||||||
# If no index is found, it means the table might be empty or scrolled beyond content
|
|
||||||
return -1
|
|
||||||
|
|
||||||
def hide_played_sections(self) -> None:
|
def hide_played_sections(self) -> None:
|
||||||
"""
|
"""
|
||||||
Scroll played sections off screen, but only if current top row is above
|
Scroll played sections off screen
|
||||||
the active header
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
active_header_row = self.get_base_model().active_section_header()
|
self.scroll_to_top(self.get_base_model().active_section_header())
|
||||||
if self.get_top_visible_row() < active_header_row:
|
|
||||||
self.scroll_to_top(active_header_row)
|
|
||||||
|
|
||||||
def _import_from_audacity(self, row_number: int) -> None:
|
def _import_from_audacity(self, row_number: int) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,694 +0,0 @@
|
|||||||
# Standard library imports
|
|
||||||
# Allow forward reference to PlaylistModel
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from operator import attrgetter
|
|
||||||
from random import shuffle
|
|
||||||
from typing import cast, Optional
|
|
||||||
import datetime as dt
|
|
||||||
|
|
||||||
# PyQt imports
|
|
||||||
from PyQt6.QtCore import (
|
|
||||||
QAbstractTableModel,
|
|
||||||
QModelIndex,
|
|
||||||
QObject,
|
|
||||||
QRegularExpression,
|
|
||||||
QSortFilterProxyModel,
|
|
||||||
Qt,
|
|
||||||
QTimer,
|
|
||||||
QVariant,
|
|
||||||
)
|
|
||||||
from PyQt6.QtGui import (
|
|
||||||
QBrush,
|
|
||||||
QColor,
|
|
||||||
QFont,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Third party imports
|
|
||||||
import line_profiler
|
|
||||||
import obswebsocket # type: ignore
|
|
||||||
|
|
||||||
# import snoop # type: ignore
|
|
||||||
|
|
||||||
# App imports
|
|
||||||
from classes import (
|
|
||||||
QueryCol,
|
|
||||||
MusicMusterSignals,
|
|
||||||
)
|
|
||||||
from config import Config
|
|
||||||
from helpers import (
|
|
||||||
ask_yes_no,
|
|
||||||
file_is_unreadable,
|
|
||||||
get_embedded_time,
|
|
||||||
get_relative_date,
|
|
||||||
ms_to_mmss,
|
|
||||||
remove_substring_case_insensitive,
|
|
||||||
set_track_metadata,
|
|
||||||
)
|
|
||||||
from log import log
|
|
||||||
from models import db, NoteColours, Playdates, PlaylistRows, Tracks
|
|
||||||
from music_manager import RowAndTrack, track_sequence
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class QueryRow:
|
|
||||||
artist: str
|
|
||||||
bitrate: int
|
|
||||||
duration: int
|
|
||||||
lastplayed: dt.datetime
|
|
||||||
path: str
|
|
||||||
title: str
|
|
||||||
track_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class QuerylistModel(QAbstractTableModel):
|
|
||||||
"""
|
|
||||||
The Querylist Model
|
|
||||||
|
|
||||||
Used to support query lists. The underlying database is never
|
|
||||||
updated. We just present tracks that match a query and allow the user
|
|
||||||
to copy those to a playlist.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
playlist_id: int,
|
|
||||||
) -> None:
|
|
||||||
log.debug("QuerylistModel.__init__()")
|
|
||||||
|
|
||||||
self.playlist_id = playlist_id
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self.querylist_rows: dict[int, QueryRow] = {}
|
|
||||||
self.signals = MusicMusterSignals()
|
|
||||||
|
|
||||||
self.signals.begin_reset_model_signal.connect(self.begin_reset_model)
|
|
||||||
self.signals.end_reset_model_signal.connect(self.end_reset_model)
|
|
||||||
|
|
||||||
with db.Session() as session:
|
|
||||||
# Populate self.playlist_rows
|
|
||||||
self.load_data(session)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def background_role(self, row: int, column: int, qrow: QueryRow) -> QBrush:
|
|
||||||
"""Return background setting"""
|
|
||||||
|
|
||||||
# Unreadable track file
|
|
||||||
if file_is_unreadable(qrow.path):
|
|
||||||
return QBrush(QColor(Config.COLOUR_UNREADABLE))
|
|
||||||
|
|
||||||
# Individual cell colouring
|
|
||||||
if column == QueryCol.BITRATE.value:
|
|
||||||
if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD:
|
|
||||||
return QBrush(QColor(Config.COLOUR_BITRATE_LOW))
|
|
||||||
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
|
|
||||||
return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM))
|
|
||||||
else:
|
|
||||||
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
|
|
||||||
|
|
||||||
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(QueryCol)
|
|
||||||
|
|
||||||
def data(
|
|
||||||
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
|
|
||||||
) -> QVariant:
|
|
||||||
"""Return data to view"""
|
|
||||||
|
|
||||||
if not index.isValid() or not (0 <= index.row() < len(self.querylist_rows)):
|
|
||||||
return QVariant()
|
|
||||||
|
|
||||||
row = index.row()
|
|
||||||
column = index.column()
|
|
||||||
# rat for playlist row data as it's used a lot
|
|
||||||
qrow = self.querylist_rows[row]
|
|
||||||
|
|
||||||
# Dispatch to role-specific functions
|
|
||||||
dispatch_table = {
|
|
||||||
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
|
|
||||||
int(Qt.ItemDataRole.DisplayRole): self.display_role,
|
|
||||||
int(Qt.ItemDataRole.EditRole): self.edit_role,
|
|
||||||
}
|
|
||||||
|
|
||||||
if role in dispatch_table:
|
|
||||||
return QVariant(dispatch_table[role](row, column, qrow))
|
|
||||||
|
|
||||||
# Document other roles but don't use them
|
|
||||||
if role in [
|
|
||||||
Qt.ItemDataRole.CheckStateRole,
|
|
||||||
Qt.ItemDataRole.DecorationRole,
|
|
||||||
Qt.ItemDataRole.FontRole,
|
|
||||||
Qt.ItemDataRole.ForegroundRole,
|
|
||||||
Qt.ItemDataRole.InitialSortOrderRole,
|
|
||||||
Qt.ItemDataRole.SizeHintRole,
|
|
||||||
Qt.ItemDataRole.StatusTipRole,
|
|
||||||
Qt.ItemDataRole.TextAlignmentRole,
|
|
||||||
Qt.ItemDataRole.ToolTipRole,
|
|
||||||
Qt.ItemDataRole.WhatsThisRole,
|
|
||||||
]:
|
|
||||||
return QVariant()
|
|
||||||
|
|
||||||
# Fall through to no-op
|
|
||||||
return QVariant()
|
|
||||||
|
|
||||||
def display_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
|
|
||||||
"""
|
|
||||||
Return text for display
|
|
||||||
"""
|
|
||||||
|
|
||||||
dispatch_table = {
|
|
||||||
QueryCol.ARTIST.value: QVariant(qrow.artist),
|
|
||||||
QueryCol.BITRATE.value: QVariant(qrow.bitrate),
|
|
||||||
QueryCol.DURATION.value: QVariant(ms_to_mmss(qrow.duration)),
|
|
||||||
QueryCol.LAST_PLAYED.value: QVariant(get_relative_date(qrow.lastplayed)),
|
|
||||||
QueryCol.TITLE.value: QVariant(qrow.title),
|
|
||||||
}
|
|
||||||
if column in dispatch_table:
|
|
||||||
return dispatch_table[column]
|
|
||||||
|
|
||||||
return QVariant()
|
|
||||||
|
|
||||||
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, qrow: QueryRow) -> QVariant:
|
|
||||||
"""
|
|
||||||
Return text for editing
|
|
||||||
"""
|
|
||||||
|
|
||||||
if column == QueryCol.TITLE.value:
|
|
||||||
return QVariant(qrow.title)
|
|
||||||
if column == QueryCol.ARTIST.value:
|
|
||||||
return QVariant(qrow.artist)
|
|
||||||
|
|
||||||
return QVariant()
|
|
||||||
|
|
||||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
|
||||||
"""
|
|
||||||
Standard model flags
|
|
||||||
"""
|
|
||||||
|
|
||||||
default = (
|
|
||||||
Qt.ItemFlag.ItemIsEnabled
|
|
||||||
| Qt.ItemFlag.ItemIsSelectable
|
|
||||||
)
|
|
||||||
if index.column() in [
|
|
||||||
QueryCol.TITLE.value,
|
|
||||||
QueryCol.ARTIST.value,
|
|
||||||
]:
|
|
||||||
return default | Qt.ItemFlag.ItemIsEditable
|
|
||||||
|
|
||||||
return default
|
|
||||||
|
|
||||||
def get_row_info(self, row_number: int) -> RowAndTrack:
|
|
||||||
"""
|
|
||||||
Return info about passed row
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.querylist_rows[row_number]
|
|
||||||
|
|
||||||
def get_row_track_id(self, row_number: int) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Return id of track associated with row or None if no track associated
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.querylist_rows[row_number].track_id
|
|
||||||
|
|
||||||
def get_row_track_path(self, row_number: int) -> str:
|
|
||||||
"""
|
|
||||||
Return path of track associated with row or empty string if no track associated
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.querylist_rows[row_number].path
|
|
||||||
|
|
||||||
def get_rows_duration(self, row_numbers: list[int]) -> int:
|
|
||||||
"""
|
|
||||||
Return the total duration of the passed rows
|
|
||||||
"""
|
|
||||||
|
|
||||||
duration = 0
|
|
||||||
for row_number in row_numbers:
|
|
||||||
duration += self.querylist_rows[row_number].duration
|
|
||||||
|
|
||||||
return duration
|
|
||||||
|
|
||||||
def invalidate_row(self, modified_row: int) -> None:
|
|
||||||
"""
|
|
||||||
Signal to view to refresh invalidated row
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.dataChanged.emit(
|
|
||||||
self.index(modified_row, 0),
|
|
||||||
self.index(modified_row, self.columnCount() - 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
def invalidate_rows(self, modified_rows: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Signal to view to refresh invlidated rows
|
|
||||||
"""
|
|
||||||
|
|
||||||
for modified_row in modified_rows:
|
|
||||||
self.invalidate_row(modified_row)
|
|
||||||
|
|
||||||
def load_data(
|
|
||||||
self, session: db.session,
|
|
||||||
sql: str = """
|
|
||||||
SELECT
|
|
||||||
tracks.*,playdates.lastplayed
|
|
||||||
FROM
|
|
||||||
tracks,playdates
|
|
||||||
WHERE
|
|
||||||
playdates.track_id=tracks.id
|
|
||||||
AND tracks.path LIKE '%/Singles/p%'
|
|
||||||
GROUP BY
|
|
||||||
tracks.id
|
|
||||||
HAVING
|
|
||||||
MAX(playdates.lastplayed) < DATE_SUB(NOW(), INTERVAL 1 YEAR)
|
|
||||||
ORDER BY tracks.title
|
|
||||||
;
|
|
||||||
"""
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Load data from user-defined query. Can probably hard-code the SELECT part
|
|
||||||
to ensure the required fields are returned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TODO: Move the SQLAlchemy parts to models later, but for now as proof
|
|
||||||
# of concept we'll keep it here.
|
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
# Clear any exsiting rows
|
|
||||||
self.querylist_rows = {}
|
|
||||||
row = 0
|
|
||||||
|
|
||||||
results = session.execute(text(sql)).mappings().all()
|
|
||||||
for result in results:
|
|
||||||
queryrow = QueryRow(
|
|
||||||
artist=result['artist'],
|
|
||||||
bitrate=result['bitrate'],
|
|
||||||
duration=result['duration'],
|
|
||||||
lastplayed=result['lastplayed'],
|
|
||||||
path=result['path'],
|
|
||||||
title=result['title'],
|
|
||||||
track_id=result['id'],
|
|
||||||
)
|
|
||||||
self.querylist_rows[row] = queryrow
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# # 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 move_rows(
|
|
||||||
self,
|
|
||||||
from_rows: list[int],
|
|
||||||
to_row_number: int,
|
|
||||||
dummy_for_profiling: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Move the playlist rows given to to_row and below.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"{self}: move_rows({from_rows=}, {to_row_number=}")
|
|
||||||
|
|
||||||
# Build a {current_row_number: new_row_number} dictionary
|
|
||||||
row_map: dict[int, int] = {}
|
|
||||||
|
|
||||||
# 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]
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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.querylist_rows):
|
|
||||||
next_to_row = len(self.querylist_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))
|
|
||||||
):
|
|
||||||
row_map[from_row] = to_row
|
|
||||||
|
|
||||||
# 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.querylist_rows.keys() if x not in from_rows],
|
|
||||||
[y for y in range(len(self.querylist_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
|
|
||||||
|
|
||||||
# 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.querylist_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)
|
|
||||||
|
|
||||||
# Update display
|
|
||||||
self.reset_track_sequence_row_numbers()
|
|
||||||
self.invalidate_rows(list(row_map.keys()))
|
|
||||||
|
|
||||||
@line_profiler.profile
|
|
||||||
def move_rows_between_playlists(
|
|
||||||
self,
|
|
||||||
from_rows: list[int],
|
|
||||||
to_row_number: int,
|
|
||||||
to_playlist_id: int,
|
|
||||||
dummy_for_profiling: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
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=}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group))
|
|
||||||
for playlist_row in PlaylistRows.plrids_to_plrs(
|
|
||||||
session,
|
|
||||||
self.playlist_id,
|
|
||||||
[self.querylist_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)
|
|
||||||
|
|
||||||
def reset_track_sequence_row_numbers(self) -> None:
|
|
||||||
"""
|
|
||||||
Signal handler for when row ordering has changed.
|
|
||||||
|
|
||||||
Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will
|
|
||||||
be correctly updated with change of row number, but track_sequence.next will still
|
|
||||||
contain row_number==4. This function fixes up the track_sequence row numbers by
|
|
||||||
looking up the playlistrow_id and retrieving the row number from the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"{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)
|
|
||||||
|
|
||||||
def _reversed_contiguous_row_groups(
|
|
||||||
self, row_numbers: list[int]
|
|
||||||
) -> list[list[int]]:
|
|
||||||
"""
|
|
||||||
Take the list of row numbers and split into groups of contiguous rows. Return as a list
|
|
||||||
of lists with the highest row numbers first.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
input [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21]
|
|
||||||
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
|
|
||||||
|
|
||||||
for idx in range(len(row_numbers)):
|
|
||||||
if row_numbers[idx] != last_value + 1:
|
|
||||||
result.append(temp)
|
|
||||||
temp = []
|
|
||||||
last_value = row_numbers[idx]
|
|
||||||
temp.append(last_value)
|
|
||||||
if temp:
|
|
||||||
result.append(temp)
|
|
||||||
result.reverse()
|
|
||||||
|
|
||||||
log.debug(f"{self}: _reversed_contiguous_row_groups() returned: {result=}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
|
|
||||||
"""Standard function for view"""
|
|
||||||
|
|
||||||
return len(self.querylist_rows)
|
|
||||||
|
|
||||||
def selection_is_sortable(self, row_numbers: list[int]) -> bool:
|
|
||||||
"""
|
|
||||||
Return True if the selection is sortable. That means:
|
|
||||||
- at least two rows selected
|
|
||||||
- selected rows are contiguous
|
|
||||||
"""
|
|
||||||
|
|
||||||
# at least two rows selected
|
|
||||||
if len(row_numbers) < 2:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# selected rows are contiguous
|
|
||||||
if sorted(row_numbers) != list(range(min(row_numbers), max(row_numbers) + 1)):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def setData(
|
|
||||||
self,
|
|
||||||
index: QModelIndex,
|
|
||||||
value: str | float,
|
|
||||||
role: int = Qt.ItemDataRole.EditRole,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Update model with edited data
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not index.isValid() or role != Qt.ItemDataRole.EditRole:
|
|
||||||
return False
|
|
||||||
|
|
||||||
row_number = index.row()
|
|
||||||
column = index.column()
|
|
||||||
|
|
||||||
with db.Session() as session:
|
|
||||||
playlist_row = session.get(
|
|
||||||
PlaylistRows, self.querylist_rows[row_number].playlistrow_id
|
|
||||||
)
|
|
||||||
if not playlist_row:
|
|
||||||
log.error(
|
|
||||||
f"{self}: Error saving data: {row_number=}, {column=}, {value=}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if column in [QueryCol.TITLE.value, QueryCol.ARTIST.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 == QueryCol.TITLE.value:
|
|
||||||
track.title = str(value)
|
|
||||||
elif column == QueryCol.ARTIST.value:
|
|
||||||
track.artist = str(value)
|
|
||||||
else:
|
|
||||||
log.error(f"{self}: Error updating track: {column=}, {value=}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# commit changes before refreshing data
|
|
||||||
session.commit()
|
|
||||||
self.refresh_row(session, row_number)
|
|
||||||
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role])
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sort_by_artist(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by artist
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "artist")
|
|
||||||
|
|
||||||
def sort_by_attribute(self, row_numbers: list[int], attr_name: str) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by passed attribute name where 'attribute' is a
|
|
||||||
key in PlaylistRowData
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Create a subset of playlist_rows with the rows we are
|
|
||||||
# interested in
|
|
||||||
shortlist_rows = {k: self.querylist_rows[k] for k in row_numbers}
|
|
||||||
sorted_list = [
|
|
||||||
playlist_row.row_number
|
|
||||||
for playlist_row in sorted(
|
|
||||||
shortlist_rows.values(), key=attrgetter(attr_name)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
self.move_rows(sorted_list, min(sorted_list))
|
|
||||||
|
|
||||||
def sort_by_duration(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by duration
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "duration")
|
|
||||||
|
|
||||||
def sort_by_lastplayed(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by lastplayed
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "lastplayed")
|
|
||||||
|
|
||||||
def sort_randomly(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows randomly
|
|
||||||
"""
|
|
||||||
|
|
||||||
shuffle(row_numbers)
|
|
||||||
self.move_rows(row_numbers, min(row_numbers))
|
|
||||||
|
|
||||||
def sort_by_title(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by title
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "title")
|
|
||||||
|
|
||||||
|
|
||||||
class QuerylistProxyModel(QSortFilterProxyModel):
|
|
||||||
"""
|
|
||||||
For searching and filtering
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
# Search all columns
|
|
||||||
self.setFilterKeyColumn(-1)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<PlaylistProxyModel: sourceModel={self.sourceModel()}>"
|
|
||||||
|
|
||||||
def set_incremental_search(self, search_string: str) -> None:
|
|
||||||
"""
|
|
||||||
Update search pattern
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.setFilterRegularExpression(
|
|
||||||
QRegularExpression(
|
|
||||||
search_string, QRegularExpression.PatternOption.CaseInsensitiveOption
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def sourceModel(self) -> QuerylistModel:
|
|
||||||
"""
|
|
||||||
Override sourceModel to return correct type
|
|
||||||
"""
|
|
||||||
|
|
||||||
return cast(QuerylistModel, super().sourceModel())
|
|
||||||
@ -1,892 +0,0 @@
|
|||||||
# Standard library imports
|
|
||||||
from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING
|
|
||||||
|
|
||||||
# PyQt imports
|
|
||||||
from PyQt6.QtCore import (
|
|
||||||
QAbstractItemModel,
|
|
||||||
QEvent,
|
|
||||||
QModelIndex,
|
|
||||||
QObject,
|
|
||||||
QItemSelection,
|
|
||||||
QSize,
|
|
||||||
Qt,
|
|
||||||
QTimer,
|
|
||||||
)
|
|
||||||
from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument
|
|
||||||
from PyQt6.QtWidgets import (
|
|
||||||
QAbstractItemDelegate,
|
|
||||||
QAbstractItemView,
|
|
||||||
QApplication,
|
|
||||||
QDoubleSpinBox,
|
|
||||||
QFrame,
|
|
||||||
QMenu,
|
|
||||||
QMessageBox,
|
|
||||||
QProxyStyle,
|
|
||||||
QStyle,
|
|
||||||
QStyledItemDelegate,
|
|
||||||
QStyleOption,
|
|
||||||
QStyleOptionViewItem,
|
|
||||||
QTableView,
|
|
||||||
QTableWidgetItem,
|
|
||||||
QTextEdit,
|
|
||||||
QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Third party imports
|
|
||||||
import line_profiler
|
|
||||||
|
|
||||||
# App imports
|
|
||||||
from audacity_controller import AudacityController
|
|
||||||
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
|
|
||||||
from config import Config
|
|
||||||
from dialogs import TrackSelectDialog
|
|
||||||
from helpers import (
|
|
||||||
ask_yes_no,
|
|
||||||
ms_to_mmss,
|
|
||||||
show_OK,
|
|
||||||
show_warning,
|
|
||||||
)
|
|
||||||
from log import log
|
|
||||||
from models import db, Settings
|
|
||||||
from music_manager import track_sequence
|
|
||||||
from querylistmodel import QuerylistModel, QuerylistProxyModel
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from musicmuster import Window
|
|
||||||
|
|
||||||
|
|
||||||
class QuerylistTab(QTableView):
|
|
||||||
"""
|
|
||||||
The querylist view
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, musicmuster: "Window", model: QuerylistProxyModel) -> None:
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
# Save passed settings
|
|
||||||
self.musicmuster = musicmuster
|
|
||||||
|
|
||||||
self.playlist_id = model.sourceModel().playlist_id
|
|
||||||
|
|
||||||
# Set up widget
|
|
||||||
self.setAlternatingRowColors(True)
|
|
||||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
|
||||||
|
|
||||||
# Set our custom style - this draws the drop indicator across the whole row
|
|
||||||
self.setStyle(PlaylistStyle())
|
|
||||||
|
|
||||||
# We will enable dragging when rows are selected. Disabling it
|
|
||||||
# here means we can click and drag to select rows.
|
|
||||||
self.setDragEnabled(False)
|
|
||||||
|
|
||||||
# Prepare for context menu
|
|
||||||
self.menu = QMenu()
|
|
||||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
||||||
self.customContextMenuRequested.connect(self._context_menu)
|
|
||||||
|
|
||||||
# Connect signals
|
|
||||||
self.signals = MusicMusterSignals()
|
|
||||||
self.signals.resize_rows_signal.connect(self.resize_rows)
|
|
||||||
self.signals.span_cells_signal.connect(self._span_cells)
|
|
||||||
|
|
||||||
# Selection model
|
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
||||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
||||||
|
|
||||||
# Set up for Audacity
|
|
||||||
try:
|
|
||||||
self.ac: Optional[AudacityController] = AudacityController()
|
|
||||||
except ApplicationError as e:
|
|
||||||
self.ac = None
|
|
||||||
show_warning(self.musicmuster, "Audacity error", str(e))
|
|
||||||
|
|
||||||
# Load model, set column widths
|
|
||||||
self.setModel(model)
|
|
||||||
self._set_column_widths()
|
|
||||||
|
|
||||||
# Stretch last column *after* setting column widths which is
|
|
||||||
# *much* faster
|
|
||||||
h_header = self.horizontalHeader()
|
|
||||||
if h_header:
|
|
||||||
h_header.sectionResized.connect(self._column_resize)
|
|
||||||
h_header.setStretchLastSection(True)
|
|
||||||
# Resize on vertical header click
|
|
||||||
v_header = self.verticalHeader()
|
|
||||||
if v_header:
|
|
||||||
v_header.setMinimumSectionSize(5)
|
|
||||||
v_header.sectionHandleDoubleClicked.disconnect()
|
|
||||||
v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
|
|
||||||
|
|
||||||
# Setting ResizeToContents causes screen flash on load
|
|
||||||
self.resize_rows()
|
|
||||||
|
|
||||||
# ########## Overridden class functions ##########
|
|
||||||
|
|
||||||
def closeEditor(
|
|
||||||
self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Override closeEditor to enable play controls and update display.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.musicmuster.action_Clear_selection.setEnabled(True)
|
|
||||||
|
|
||||||
super(PlaylistTab, self).closeEditor(editor, hint)
|
|
||||||
|
|
||||||
# Optimise row heights after increasing row height for editing
|
|
||||||
self.resize_rows()
|
|
||||||
|
|
||||||
# Update start times in case a start time in a note has been
|
|
||||||
# edited
|
|
||||||
self.get_base_model().update_track_times()
|
|
||||||
|
|
||||||
# Deselect edited line
|
|
||||||
self.clear_selection()
|
|
||||||
|
|
||||||
@line_profiler.profile
|
|
||||||
def dropEvent(
|
|
||||||
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Move dropped rows
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not event:
|
|
||||||
return
|
|
||||||
|
|
||||||
if event.source() is not self or (
|
|
||||||
event.dropAction() != Qt.DropAction.MoveAction
|
|
||||||
and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove
|
|
||||||
):
|
|
||||||
return super().dropEvent(event)
|
|
||||||
|
|
||||||
from_rows = self.selected_model_row_numbers()
|
|
||||||
to_index = self.indexAt(event.position().toPoint())
|
|
||||||
|
|
||||||
# The drop indicator can either be immediately below a row or
|
|
||||||
# immediately above a row. There's about a 1 pixel difference,
|
|
||||||
# but we always want to drop between rows regardless of where
|
|
||||||
# drop indicator is.
|
|
||||||
if (
|
|
||||||
self.dropIndicatorPosition()
|
|
||||||
== QAbstractItemView.DropIndicatorPosition.BelowItem
|
|
||||||
):
|
|
||||||
# Drop on the row below
|
|
||||||
next_row = to_index.row() + 1
|
|
||||||
if next_row < self.model().rowCount(): # Ensure the row exists
|
|
||||||
destination_index = to_index.siblingAtRow(next_row)
|
|
||||||
else:
|
|
||||||
# Handle edge case where next_row is beyond the last row
|
|
||||||
destination_index = to_index
|
|
||||||
else:
|
|
||||||
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()
|
|
||||||
if (
|
|
||||||
0 <= min(from_rows) <= base_model_row_count
|
|
||||||
and 0 <= to_model_row <= base_model_row_count
|
|
||||||
):
|
|
||||||
# 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 to_model_row == track_sequence.current.row_number + 1
|
|
||||||
):
|
|
||||||
set_next_row = to_model_row
|
|
||||||
|
|
||||||
self.get_base_model().move_rows(from_rows, to_model_row)
|
|
||||||
|
|
||||||
# Reset drag mode to allow row selection by dragging
|
|
||||||
self.setDragEnabled(False)
|
|
||||||
|
|
||||||
# Deselect rows
|
|
||||||
self.clear_selection()
|
|
||||||
|
|
||||||
# Resize rows
|
|
||||||
self.resize_rows()
|
|
||||||
|
|
||||||
# Set next row if we are immediately under current row
|
|
||||||
if set_next_row:
|
|
||||||
self.get_base_model().set_next_row(set_next_row)
|
|
||||||
|
|
||||||
event.accept()
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
|
||||||
"""
|
|
||||||
Enable dragging if rows are selected
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.selectedIndexes():
|
|
||||||
self.setDragEnabled(True)
|
|
||||||
else:
|
|
||||||
self.setDragEnabled(False)
|
|
||||||
self.reset()
|
|
||||||
super().mouseReleaseEvent(event)
|
|
||||||
|
|
||||||
def resizeRowToContents(self, row):
|
|
||||||
super().resizeRowToContents(row)
|
|
||||||
self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))
|
|
||||||
|
|
||||||
def resizeRowsToContents(self):
|
|
||||||
header = self.verticalHeader()
|
|
||||||
for row in range(self.model().rowCount()):
|
|
||||||
hint = self.sizeHintForRow(row)
|
|
||||||
header.resizeSection(row, hint)
|
|
||||||
|
|
||||||
def selectionChanged(
|
|
||||||
self, selected: QItemSelection, deselected: QItemSelection
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Toggle drag behaviour according to whether rows are selected
|
|
||||||
"""
|
|
||||||
|
|
||||||
selected_rows = self.get_selected_rows()
|
|
||||||
self.musicmuster.current.selected_rows = selected_rows
|
|
||||||
|
|
||||||
# If no rows are selected, we have nothing to do
|
|
||||||
if len(selected_rows) == 0:
|
|
||||||
self.musicmuster.lblSumPlaytime.setText("")
|
|
||||||
else:
|
|
||||||
if not self.musicmuster.disable_selection_timing:
|
|
||||||
selected_duration = self.get_base_model().get_rows_duration(
|
|
||||||
self.get_selected_rows()
|
|
||||||
)
|
|
||||||
if selected_duration > 0:
|
|
||||||
self.musicmuster.lblSumPlaytime.setText(
|
|
||||||
f"Selected duration: {ms_to_mmss(selected_duration)}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.musicmuster.lblSumPlaytime.setText("")
|
|
||||||
else:
|
|
||||||
log.debug(
|
|
||||||
f"playlists.py.selectionChanged: {self.musicmuster.disable_selection_timing=}"
|
|
||||||
)
|
|
||||||
|
|
||||||
super().selectionChanged(selected, deselected)
|
|
||||||
|
|
||||||
# ########## Custom functions ##########
|
|
||||||
def _add_context_menu(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
action: Callable,
|
|
||||||
disabled: bool = False,
|
|
||||||
parent_menu: Optional[QMenu] = None,
|
|
||||||
) -> Optional[QAction]:
|
|
||||||
"""
|
|
||||||
Add item to self.menu
|
|
||||||
"""
|
|
||||||
|
|
||||||
if parent_menu is None:
|
|
||||||
parent_menu = self.menu
|
|
||||||
|
|
||||||
menu_item = parent_menu.addAction(text)
|
|
||||||
if not menu_item:
|
|
||||||
return None
|
|
||||||
menu_item.setDisabled(disabled)
|
|
||||||
menu_item.triggered.connect(action)
|
|
||||||
|
|
||||||
return menu_item
|
|
||||||
|
|
||||||
def _add_track(self) -> None:
|
|
||||||
"""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()
|
|
||||||
|
|
||||||
def _build_context_menu(self, item: QTableWidgetItem) -> None:
|
|
||||||
"""Used to process context (right-click) menu, which is defined here"""
|
|
||||||
|
|
||||||
self.menu.clear()
|
|
||||||
|
|
||||||
index = self.model().index(item.row(), item.column())
|
|
||||||
model_row_number = self.model().mapToSource(index).row()
|
|
||||||
base_model = self.get_base_model()
|
|
||||||
|
|
||||||
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
|
|
||||||
else:
|
|
||||||
this_is_current_row = False
|
|
||||||
if track_sequence.next:
|
|
||||||
this_is_next_row = model_row_number == track_sequence.next.row_number
|
|
||||||
else:
|
|
||||||
this_is_next_row = False
|
|
||||||
track_path = base_model.get_row_info(model_row_number).path
|
|
||||||
|
|
||||||
# Open/import in/from Audacity
|
|
||||||
if track_row and not this_is_current_row:
|
|
||||||
if self.ac and track_path == self.ac.path:
|
|
||||||
# This track was opened in Audacity
|
|
||||||
self._add_context_menu(
|
|
||||||
"Update from Audacity",
|
|
||||||
lambda: self._import_from_audacity(model_row_number),
|
|
||||||
)
|
|
||||||
self._add_context_menu(
|
|
||||||
"Cancel Audacity",
|
|
||||||
lambda: self._cancel_audacity(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._add_context_menu(
|
|
||||||
"Open in Audacity", lambda: self._open_in_audacity(model_row_number)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Rescan
|
|
||||||
if track_row and not this_is_current_row:
|
|
||||||
self._add_context_menu(
|
|
||||||
"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:
|
|
||||||
self._add_context_menu(
|
|
||||||
"Move selected rows here", lambda: self._move_selected_rows()
|
|
||||||
)
|
|
||||||
|
|
||||||
# ----------------------
|
|
||||||
self.menu.addSeparator()
|
|
||||||
|
|
||||||
# Delete row
|
|
||||||
if not this_is_current_row and not this_is_next_row:
|
|
||||||
self._add_context_menu("Delete row", lambda: self._delete_rows())
|
|
||||||
|
|
||||||
# Remove track from row
|
|
||||||
if track_row and not this_is_current_row and not this_is_next_row:
|
|
||||||
self._add_context_menu(
|
|
||||||
"Remove track from row",
|
|
||||||
lambda: base_model.remove_track(model_row_number),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove comments
|
|
||||||
self._add_context_menu("Remove comments", lambda: self._remove_comments())
|
|
||||||
|
|
||||||
# Add track to section header (ie, make this a track row)
|
|
||||||
if header_row:
|
|
||||||
self._add_context_menu("Add a track", lambda: self._add_track())
|
|
||||||
|
|
||||||
# # ----------------------
|
|
||||||
self.menu.addSeparator()
|
|
||||||
|
|
||||||
# Mark unplayed
|
|
||||||
if track_row and base_model.is_played_row(model_row_number):
|
|
||||||
self._add_context_menu(
|
|
||||||
"Mark unplayed",
|
|
||||||
lambda: self._mark_as_unplayed(self.get_selected_rows()),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Unmark as next
|
|
||||||
if this_is_next_row:
|
|
||||||
self._add_context_menu(
|
|
||||||
"Unmark as next track", lambda: self._unmark_as_next()
|
|
||||||
)
|
|
||||||
|
|
||||||
# ----------------------
|
|
||||||
self.menu.addSeparator()
|
|
||||||
|
|
||||||
# Sort
|
|
||||||
sort_menu = self.menu.addMenu("Sort")
|
|
||||||
self._add_context_menu(
|
|
||||||
"by title",
|
|
||||||
lambda: base_model.sort_by_title(self.get_selected_rows()),
|
|
||||||
parent_menu=sort_menu,
|
|
||||||
)
|
|
||||||
self._add_context_menu(
|
|
||||||
"by artist",
|
|
||||||
lambda: base_model.sort_by_artist(self.get_selected_rows()),
|
|
||||||
parent_menu=sort_menu,
|
|
||||||
)
|
|
||||||
self._add_context_menu(
|
|
||||||
"by duration",
|
|
||||||
lambda: base_model.sort_by_duration(self.get_selected_rows()),
|
|
||||||
parent_menu=sort_menu,
|
|
||||||
)
|
|
||||||
self._add_context_menu(
|
|
||||||
"by last played",
|
|
||||||
lambda: base_model.sort_by_lastplayed(self.get_selected_rows()),
|
|
||||||
parent_menu=sort_menu,
|
|
||||||
)
|
|
||||||
self._add_context_menu(
|
|
||||||
"randomly",
|
|
||||||
lambda: base_model.sort_randomly(self.get_selected_rows()),
|
|
||||||
parent_menu=sort_menu,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Info
|
|
||||||
if track_row:
|
|
||||||
self._add_context_menu("Info", lambda: self._info_row(model_row_number))
|
|
||||||
|
|
||||||
# Track path
|
|
||||||
if track_row:
|
|
||||||
self._add_context_menu(
|
|
||||||
"Copy track path", lambda: self._copy_path(model_row_number)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cancel_audacity(self) -> None:
|
|
||||||
"""
|
|
||||||
Cancel Audacity editing. We don't do anything with Audacity, just "forget"
|
|
||||||
that we have an edit open.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.ac:
|
|
||||||
self.ac.path = None
|
|
||||||
|
|
||||||
def clear_selection(self) -> None:
|
|
||||||
"""Unselect all tracks and reset drag mode"""
|
|
||||||
|
|
||||||
self.clearSelection()
|
|
||||||
# We want to remove the focus from any widget otherwise keyboard
|
|
||||||
# activity may edit a cell.
|
|
||||||
fw = self.musicmuster.focusWidget()
|
|
||||||
if fw:
|
|
||||||
fw.clearFocus()
|
|
||||||
self.setDragEnabled(False)
|
|
||||||
|
|
||||||
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
# Resize rows if necessary
|
|
||||||
self.resizeRowsToContents()
|
|
||||||
|
|
||||||
with db.Session() as session:
|
|
||||||
attr_name = f"querylist_col_{column_number}_width"
|
|
||||||
record = Settings.get_setting(session, attr_name)
|
|
||||||
record.f_int = self.columnWidth(column_number)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def _context_menu(self, pos):
|
|
||||||
"""Display right-click menu"""
|
|
||||||
|
|
||||||
item = self.indexAt(pos)
|
|
||||||
self._build_context_menu(item)
|
|
||||||
self.menu.exec(self.mapToGlobal(pos))
|
|
||||||
|
|
||||||
def _copy_path(self, row_number: int) -> None:
|
|
||||||
"""
|
|
||||||
If passed row_number has a track, copy the track path, single-quoted,
|
|
||||||
to the clipboard. Otherwise, return None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
track_path = self.get_base_model().get_row_info(row_number).path
|
|
||||||
if not track_path:
|
|
||||||
return
|
|
||||||
|
|
||||||
replacements = [
|
|
||||||
("'", "\\'"),
|
|
||||||
(" ", "\\ "),
|
|
||||||
("(", "\\("),
|
|
||||||
(")", "\\)"),
|
|
||||||
]
|
|
||||||
for old, new in replacements:
|
|
||||||
track_path = track_path.replace(old, new)
|
|
||||||
|
|
||||||
cb = QApplication.clipboard()
|
|
||||||
if cb:
|
|
||||||
cb.clear(mode=cb.Mode.Clipboard)
|
|
||||||
cb.setText(track_path, mode=cb.Mode.Clipboard)
|
|
||||||
|
|
||||||
def current_track_started(self) -> None:
|
|
||||||
"""
|
|
||||||
Called when track starts playing
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.get_base_model().current_track_started()
|
|
||||||
# Scroll to current section if hide mode is by section
|
|
||||||
if (
|
|
||||||
self.musicmuster.hide_played_tracks
|
|
||||||
and Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS
|
|
||||||
):
|
|
||||||
# Hide section after delay
|
|
||||||
QTimer.singleShot(
|
|
||||||
Config.HIDE_AFTER_PLAYING_OFFSET + 100,
|
|
||||||
lambda: self.hide_played_sections(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _delete_rows(self) -> None:
|
|
||||||
"""
|
|
||||||
Delete mutliple rows
|
|
||||||
|
|
||||||
Actions required:
|
|
||||||
- Confirm deletion should go ahead
|
|
||||||
- Pass to model to do the deed
|
|
||||||
"""
|
|
||||||
|
|
||||||
rows_to_delete = self.get_selected_rows()
|
|
||||||
log.debug(f"_delete_rows({rows_to_delete=}")
|
|
||||||
row_count = len(rows_to_delete)
|
|
||||||
if row_count < 1:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get confirmation
|
|
||||||
plural = "s" if row_count > 1 else ""
|
|
||||||
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
|
|
||||||
return
|
|
||||||
|
|
||||||
base_model = self.get_base_model()
|
|
||||||
|
|
||||||
base_model.delete_rows(self.selected_model_row_numbers())
|
|
||||||
self.clear_selection()
|
|
||||||
|
|
||||||
def get_base_model(self) -> QuerylistModel:
|
|
||||||
"""
|
|
||||||
Return the base model for this proxy model
|
|
||||||
"""
|
|
||||||
|
|
||||||
return cast(QuerylistModel, self.model().sourceModel())
|
|
||||||
|
|
||||||
def get_selected_row_track_info(self) -> Optional[TrackInfo]:
|
|
||||||
"""
|
|
||||||
Return the track_id and row number of the selected
|
|
||||||
row. If no row selected or selected row does not have a track,
|
|
||||||
return None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
selected_row = self.get_selected_row()
|
|
||||||
if selected_row is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
base_model = self.get_base_model()
|
|
||||||
model_row_number = self.source_model_selected_row_number()
|
|
||||||
|
|
||||||
if model_row_number is None:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
track_id = base_model.get_row_track_id(model_row_number)
|
|
||||||
if not track_id:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return TrackInfo(track_id, selected_row)
|
|
||||||
|
|
||||||
def get_selected_row(self) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Return selected row number. If no rows or multiple rows selected, return None
|
|
||||||
"""
|
|
||||||
|
|
||||||
selected = self.get_selected_rows()
|
|
||||||
if len(selected) == 1:
|
|
||||||
return selected[0]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_selected_rows(self) -> List[int]:
|
|
||||||
"""Return a list of model-selected row numbers sorted by row"""
|
|
||||||
|
|
||||||
# Use a set to deduplicate result (a selected row will have all
|
|
||||||
# items in that row selected)
|
|
||||||
result = sorted(
|
|
||||||
list(
|
|
||||||
set([self.model().mapToSource(a).row() for a in self.selectedIndexes()])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
log.debug(f"get_selected_rows() returned: {result=}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def hide_played_sections(self) -> None:
|
|
||||||
"""
|
|
||||||
Scroll played sections off screen
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.scroll_to_top(self.get_base_model().active_section_header())
|
|
||||||
|
|
||||||
def _import_from_audacity(self, row_number: int) -> None:
|
|
||||||
"""
|
|
||||||
Import current Audacity track to passed row
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self.ac:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self.ac.export()
|
|
||||||
self._rescan(row_number)
|
|
||||||
except ApplicationError as e:
|
|
||||||
show_warning(self.musicmuster, "Audacity error", str(e))
|
|
||||||
self._cancel_audacity()
|
|
||||||
|
|
||||||
def _info_row(self, row_number: int) -> None:
|
|
||||||
"""Display popup with info re row"""
|
|
||||||
|
|
||||||
prd = self.get_base_model().get_row_info(row_number)
|
|
||||||
if prd:
|
|
||||||
txt = (
|
|
||||||
f"Title: {prd.title}\n"
|
|
||||||
f"Artist: {prd.artist}\n"
|
|
||||||
f"Track ID: {prd.track_id}\n"
|
|
||||||
f"Track duration: {ms_to_mmss(prd.duration)}\n"
|
|
||||||
f"Track bitrate: {prd.bitrate}\n"
|
|
||||||
"\n\n"
|
|
||||||
f"Path: {prd.path}\n"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
txt = f"Can't find info about row{row_number}"
|
|
||||||
|
|
||||||
show_OK(self.musicmuster, "Track info", txt)
|
|
||||||
|
|
||||||
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
|
|
||||||
"""Mark row as unplayed"""
|
|
||||||
|
|
||||||
self.get_base_model().mark_unplayed(row_numbers)
|
|
||||||
self.clear_selection()
|
|
||||||
|
|
||||||
def _mark_for_moving(self) -> None:
|
|
||||||
"""
|
|
||||||
Mark selected rows for pasting
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.musicmuster.mark_rows_for_moving()
|
|
||||||
|
|
||||||
def model(self) -> QuerylistProxyModel:
|
|
||||||
"""
|
|
||||||
Override return type to keep mypy happy in this module
|
|
||||||
"""
|
|
||||||
|
|
||||||
return cast(QuerylistProxyModel, super().model())
|
|
||||||
|
|
||||||
def _move_selected_rows(self) -> None:
|
|
||||||
"""
|
|
||||||
Move selected rows here
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.musicmuster.paste_rows()
|
|
||||||
|
|
||||||
def _open_in_audacity(self, row_number: int) -> None:
|
|
||||||
"""
|
|
||||||
Open track in passed row in Audacity
|
|
||||||
"""
|
|
||||||
|
|
||||||
path = self.get_base_model().get_row_track_path(row_number)
|
|
||||||
if not path:
|
|
||||||
log.error(f"_open_in_audacity: can't get path for {row_number=}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not self.ac:
|
|
||||||
self.ac = AudacityController()
|
|
||||||
self.ac.open(path)
|
|
||||||
except ApplicationError as e:
|
|
||||||
show_warning(self.musicmuster, "Audacity error", str(e))
|
|
||||||
|
|
||||||
def _remove_comments(self) -> None:
|
|
||||||
"""
|
|
||||||
Remove comments from selected rows
|
|
||||||
"""
|
|
||||||
|
|
||||||
row_numbers = self.selected_model_row_numbers()
|
|
||||||
if not row_numbers:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.get_base_model().remove_comments(row_numbers)
|
|
||||||
|
|
||||||
def _rescan(self, row_number: int) -> None:
|
|
||||||
"""Rescan track"""
|
|
||||||
|
|
||||||
self.get_base_model().rescan_track(row_number)
|
|
||||||
self.clear_selection()
|
|
||||||
|
|
||||||
def resize_rows(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
|
|
||||||
|
|
||||||
# Suggestion from phind.com
|
|
||||||
def resize_row(row, count=1):
|
|
||||||
row_count = self.model().rowCount()
|
|
||||||
for todo in range(count):
|
|
||||||
if row < row_count:
|
|
||||||
self.resizeRowToContents(row)
|
|
||||||
row += 1
|
|
||||||
if row < row_count:
|
|
||||||
QTimer.singleShot(0, lambda: resize_row(row, count))
|
|
||||||
|
|
||||||
# Start resizing from row 0, 10 rows at a time
|
|
||||||
QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE))
|
|
||||||
|
|
||||||
def scroll_to_top(self, row_number: int) -> None:
|
|
||||||
"""
|
|
||||||
Scroll to put passed row_number at the top of the displayed playlist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if row_number is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
row_index = self.model().index(row_number, 0)
|
|
||||||
self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop)
|
|
||||||
|
|
||||||
def select_duplicate_rows(self) -> None:
|
|
||||||
"""
|
|
||||||
Select the last of any rows with duplicate tracks in current playlist.
|
|
||||||
This allows the selection to typically come towards the end of the playlist away
|
|
||||||
from any show specific sections.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Clear any selected rows to avoid confustion
|
|
||||||
self.clear_selection()
|
|
||||||
# We need to be in MultiSelection mode
|
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
|
|
||||||
# Get the duplicate rows
|
|
||||||
duplicate_rows = self.get_base_model().get_duplicate_rows()
|
|
||||||
# Select the rows
|
|
||||||
for duplicate_row in duplicate_rows:
|
|
||||||
self.selectRow(duplicate_row)
|
|
||||||
# Reset selection mode
|
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
||||||
|
|
||||||
def source_model_selected_row_number(self) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Return the model row number corresponding to the selected row or None
|
|
||||||
"""
|
|
||||||
|
|
||||||
selected_index = self._selected_row_index()
|
|
||||||
if selected_index is None:
|
|
||||||
return None
|
|
||||||
return self.model().mapToSource(selected_index).row()
|
|
||||||
|
|
||||||
def selected_model_row_numbers(self) -> List[int]:
|
|
||||||
"""
|
|
||||||
Return a list of model row numbers corresponding to the selected rows or
|
|
||||||
an empty list.
|
|
||||||
"""
|
|
||||||
|
|
||||||
selected_indexes = self._selected_row_indexes()
|
|
||||||
if selected_indexes is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [self.model().mapToSource(a).row() for a in selected_indexes]
|
|
||||||
|
|
||||||
def _selected_row_index(self) -> Optional[QModelIndex]:
|
|
||||||
"""
|
|
||||||
Return the selected row index or None if none selected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
row_indexes = self._selected_row_indexes()
|
|
||||||
|
|
||||||
if len(row_indexes) > 1:
|
|
||||||
show_warning(
|
|
||||||
self.musicmuster, "Multiple rows selected", "Select only one row"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
elif not row_indexes:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return row_indexes[0]
|
|
||||||
|
|
||||||
def _selected_row_indexes(self) -> List[QModelIndex]:
|
|
||||||
"""
|
|
||||||
Return a list of indexes of column 0 of selected rows
|
|
||||||
"""
|
|
||||||
|
|
||||||
sm = self.selectionModel()
|
|
||||||
if sm and sm.hasSelection():
|
|
||||||
return sm.selectedRows()
|
|
||||||
return []
|
|
||||||
|
|
||||||
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"querylist_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)
|
|
||||||
|
|
||||||
def set_row_as_next_track(self) -> None:
|
|
||||||
"""
|
|
||||||
Set selected row as next track
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_row_number = self.source_model_selected_row_number()
|
|
||||||
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.clearSelection()
|
|
||||||
|
|
||||||
def _span_cells(
|
|
||||||
self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Implement spanning of cells, initiated by signal
|
|
||||||
|
|
||||||
row and column are from the base model so we need to translate
|
|
||||||
the row into this display row
|
|
||||||
"""
|
|
||||||
|
|
||||||
if playlist_id != self.playlist_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
base_model = self.get_base_model()
|
|
||||||
|
|
||||||
cell_index = self.model().mapFromSource(base_model.createIndex(row, column))
|
|
||||||
row = cell_index.row()
|
|
||||||
column = cell_index.column()
|
|
||||||
|
|
||||||
# Don't set spanning if already in place because that is seen as
|
|
||||||
# a change to the view and thus it refreshes the data which
|
|
||||||
# again calls us here.
|
|
||||||
if (
|
|
||||||
self.rowSpan(row, column) == rowSpan
|
|
||||||
and self.columnSpan(row, column) == columnSpan
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
self.setSpan(row, column, rowSpan, columnSpan)
|
|
||||||
|
|
||||||
def tab_live(self) -> None:
|
|
||||||
"""
|
|
||||||
Called when tab gets focus
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 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.resize_rows()
|
|
||||||
|
|
||||||
def _unmark_as_next(self) -> None:
|
|
||||||
"""Rescan track"""
|
|
||||||
|
|
||||||
track_sequence.set_next(None)
|
|
||||||
self.clear_selection()
|
|
||||||
self.signals.next_track_changed_signal.emit()
|
|
||||||
@ -997,9 +997,6 @@ padding-left: 8px;</string>
|
|||||||
<addaction name="actionRenamePlaylist"/>
|
<addaction name="actionRenamePlaylist"/>
|
||||||
<addaction name="actionDeletePlaylist"/>
|
<addaction name="actionDeletePlaylist"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="actionOpenQuerylist"/>
|
|
||||||
<addaction name="actionManage_querylists"/>
|
|
||||||
<addaction name="separator"/>
|
|
||||||
<addaction name="actionSave_as_template"/>
|
<addaction name="actionSave_as_template"/>
|
||||||
<addaction name="actionManage_templates"/>
|
<addaction name="actionManage_templates"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
@ -1143,7 +1140,7 @@ padding-left: 8px;</string>
|
|||||||
</action>
|
</action>
|
||||||
<action name="actionOpenPlaylist">
|
<action name="actionOpenPlaylist">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Open &playlist...</string>
|
<string>O&pen...</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionNewPlaylist">
|
<action name="actionNewPlaylist">
|
||||||
@ -1372,16 +1369,6 @@ padding-left: 8px;</string>
|
|||||||
<string>Import files...</string>
|
<string>Import files...</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionOpenQuerylist">
|
|
||||||
<property name="text">
|
|
||||||
<string>Open &querylist...</string>
|
|
||||||
</property>
|
|
||||||
</action>
|
|
||||||
<action name="actionManage_querylists">
|
|
||||||
<property name="text">
|
|
||||||
<string>Manage querylists...</string>
|
|
||||||
</property>
|
|
||||||
</action>
|
|
||||||
</widget>
|
</widget>
|
||||||
<customwidgets>
|
<customwidgets>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
|
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
|
||||||
#
|
#
|
||||||
# Created by: PyQt6 UI code generator 6.8.1
|
# Created by: PyQt6 UI code generator 6.8.0
|
||||||
#
|
#
|
||||||
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
|
# 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.
|
# run again. Do not edit this file unless you know what you are doing.
|
||||||
@ -531,10 +531,6 @@ class Ui_MainWindow(object):
|
|||||||
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
|
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
|
||||||
self.actionImport_files = QtGui.QAction(parent=MainWindow)
|
self.actionImport_files = QtGui.QAction(parent=MainWindow)
|
||||||
self.actionImport_files.setObjectName("actionImport_files")
|
self.actionImport_files.setObjectName("actionImport_files")
|
||||||
self.actionOpenQuerylist = QtGui.QAction(parent=MainWindow)
|
|
||||||
self.actionOpenQuerylist.setObjectName("actionOpenQuerylist")
|
|
||||||
self.actionManage_querylists = QtGui.QAction(parent=MainWindow)
|
|
||||||
self.actionManage_querylists.setObjectName("actionManage_querylists")
|
|
||||||
self.menuFile.addSeparator()
|
self.menuFile.addSeparator()
|
||||||
self.menuFile.addAction(self.actionInsertTrack)
|
self.menuFile.addAction(self.actionInsertTrack)
|
||||||
self.menuFile.addAction(self.actionRemove)
|
self.menuFile.addAction(self.actionRemove)
|
||||||
@ -558,9 +554,6 @@ class Ui_MainWindow(object):
|
|||||||
self.menuPlaylist.addAction(self.actionRenamePlaylist)
|
self.menuPlaylist.addAction(self.actionRenamePlaylist)
|
||||||
self.menuPlaylist.addAction(self.actionDeletePlaylist)
|
self.menuPlaylist.addAction(self.actionDeletePlaylist)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuPlaylist.addSeparator()
|
||||||
self.menuPlaylist.addAction(self.actionOpenQuerylist)
|
|
||||||
self.menuPlaylist.addAction(self.actionManage_querylists)
|
|
||||||
self.menuPlaylist.addSeparator()
|
|
||||||
self.menuPlaylist.addAction(self.actionSave_as_template)
|
self.menuPlaylist.addAction(self.actionSave_as_template)
|
||||||
self.menuPlaylist.addAction(self.actionManage_templates)
|
self.menuPlaylist.addAction(self.actionManage_templates)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuPlaylist.addSeparator()
|
||||||
@ -634,7 +627,7 @@ class Ui_MainWindow(object):
|
|||||||
self.action_Resume_previous.setText(_translate("MainWindow", "&Resume previous"))
|
self.action_Resume_previous.setText(_translate("MainWindow", "&Resume previous"))
|
||||||
self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
|
self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
|
||||||
self.actionTest.setText(_translate("MainWindow", "&Test"))
|
self.actionTest.setText(_translate("MainWindow", "&Test"))
|
||||||
self.actionOpenPlaylist.setText(_translate("MainWindow", "Open &playlist..."))
|
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
|
||||||
self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
|
self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
|
||||||
self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
|
self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
|
||||||
self.actionSkipToFade.setText(_translate("MainWindow", "&Skip to start of fade"))
|
self.actionSkipToFade.setText(_translate("MainWindow", "&Skip to start of fade"))
|
||||||
@ -684,7 +677,5 @@ class Ui_MainWindow(object):
|
|||||||
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
|
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
|
||||||
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
|
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
|
||||||
self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
|
self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
|
||||||
self.actionOpenQuerylist.setText(_translate("MainWindow", "Open &querylist..."))
|
|
||||||
self.actionManage_querylists.setText(_translate("MainWindow", "Manage querylists..."))
|
|
||||||
from infotabs import InfoTabs
|
from infotabs import InfoTabs
|
||||||
from pyqtgraph import PlotWidget
|
from pyqtgraph import PlotWidget # type: ignore
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
env.py.DEBUG
|
|
||||||
27
migrations/env.py
Normal file
27
migrations/env.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# import the application's Alchemical instance
|
||||||
|
try:
|
||||||
|
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.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
run_migrations(db, {
|
||||||
|
'render_as_batch': True,
|
||||||
|
'compare_type': True,
|
||||||
|
})
|
||||||
@ -1,28 +0,0 @@
|
|||||||
from importlib import import_module
|
|
||||||
from alembic import context
|
|
||||||
from alchemical.alembic.env import run_migrations
|
|
||||||
|
|
||||||
# Load Alembic configuration
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 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)
|
|
||||||
print(f"Successfully loaded Alchemical database instance: {db}")
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
})
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# import the application's Alchemical instance
|
|
||||||
try:
|
|
||||||
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.'
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
run_migrations(db, {
|
|
||||||
'render_as_batch': True,
|
|
||||||
'compare_type': True,
|
|
||||||
})
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
"""Index for notesolours substring
|
|
||||||
|
|
||||||
Revision ID: c76e865ccb85
|
|
||||||
Revises: 33c04e3c12c8
|
|
||||||
Create Date: 2025-02-07 18:21:01.760057
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'c76e865ccb85'
|
|
||||||
down_revision = '33c04e3c12c8'
|
|
||||||
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.create_index(batch_op.f('ix_notecolours_substring'), ['substring'], unique=False)
|
|
||||||
|
|
||||||
with op.batch_alter_table('playdates', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint('fk_playdates_track_id_tracks', type_='foreignkey')
|
|
||||||
batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE')
|
|
||||||
|
|
||||||
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint('playlist_rows_ibfk_1', type_='foreignkey')
|
|
||||||
batch_op.create_foreign_key(None, 'tracks', ['track_id'], ['id'], ondelete='CASCADE')
|
|
||||||
|
|
||||||
with op.batch_alter_table('queries', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint('fk_queries_playlist_id_playlists', type_='foreignkey')
|
|
||||||
batch_op.create_foreign_key(None, 'playlists', ['playlist_id'], ['id'], ondelete='CASCADE')
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade_() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('queries', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint(None, type_='foreignkey')
|
|
||||||
batch_op.create_foreign_key('fk_queries_playlist_id_playlists', 'playlists', ['playlist_id'], ['id'])
|
|
||||||
|
|
||||||
with op.batch_alter_table('playlist_rows', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint(None, type_='foreignkey')
|
|
||||||
batch_op.create_foreign_key('playlist_rows_ibfk_1', 'tracks', ['track_id'], ['id'])
|
|
||||||
|
|
||||||
with op.batch_alter_table('playdates', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint(None, type_='foreignkey')
|
|
||||||
batch_op.create_foreign_key('fk_playdates_track_id_tracks', 'tracks', ['track_id'], ['id'])
|
|
||||||
|
|
||||||
with op.batch_alter_table('notecolours', schema=None) as batch_op:
|
|
||||||
batch_op.drop_index(batch_op.f('ix_notecolours_substring'))
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user