WIP: query tabs
This commit is contained in:
parent
7a98fe3920
commit
f4a5ecf79e
@ -14,6 +14,11 @@ from PyQt6.QtCore import (
|
||||
pyqtSignal,
|
||||
QObject,
|
||||
)
|
||||
from PyQt6.QtWidgets import (
|
||||
QProxyStyle,
|
||||
QStyle,
|
||||
QStyleOption,
|
||||
)
|
||||
|
||||
# App imports
|
||||
|
||||
@ -31,6 +36,14 @@ class Col(Enum):
|
||||
NOTE = auto()
|
||||
|
||||
|
||||
class QueryCol(Enum):
|
||||
TITLE = auto()
|
||||
ARTIST = auto()
|
||||
DURATION = auto()
|
||||
LAST_PLAYED = auto()
|
||||
BITRATE = auto()
|
||||
|
||||
|
||||
def singleton(cls):
|
||||
"""
|
||||
Make a class a Singleton class (see
|
||||
@ -100,6 +113,24 @@ class MusicMusterSignals(QObject):
|
||||
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):
|
||||
artist: str = ""
|
||||
title: str = ""
|
||||
|
||||
@ -18,7 +18,6 @@ class DatabaseManager:
|
||||
def __init__(self, database_url: str, **kwargs: dict) -> None:
|
||||
if DatabaseManager.__instance is None:
|
||||
self.db = Alchemical(database_url, **kwargs)
|
||||
self.db.create_all()
|
||||
DatabaseManager.__instance = self
|
||||
else:
|
||||
raise Exception("Attempted to create a second DatabaseManager instance")
|
||||
|
||||
@ -19,7 +19,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# App imports
|
||||
@ -36,7 +36,7 @@ if DATABASE_URL is None:
|
||||
if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
|
||||
raise ValueError("Unit tests running on non-Sqlite database")
|
||||
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
|
||||
db.create_all()
|
||||
# db.create_all()
|
||||
|
||||
|
||||
# Database classes
|
||||
@ -236,10 +236,23 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
|
||||
return session.scalars(
|
||||
select(cls)
|
||||
.filter(cls.is_template.is_(False))
|
||||
.filter(
|
||||
cls.is_template.is_(False),
|
||||
~cls.query.has()
|
||||
)
|
||||
.order_by(cls.last_used.desc())
|
||||
).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
|
||||
def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
|
||||
"""Returns a list of all templates ordered by name"""
|
||||
@ -257,6 +270,7 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
.filter(
|
||||
cls.open.is_(False),
|
||||
cls.is_template.is_(False),
|
||||
~cls.query.has()
|
||||
)
|
||||
.order_by(cls.last_used.desc())
|
||||
).all()
|
||||
@ -268,7 +282,13 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
"""
|
||||
|
||||
return session.scalars(
|
||||
select(cls).where(cls.open.is_(True)).order_by(cls.tab)
|
||||
select(cls)
|
||||
.where(
|
||||
cls.open.is_(True),
|
||||
~cls.query.has()
|
||||
)
|
||||
.order_by(cls.tab)
|
||||
|
||||
).all()
|
||||
|
||||
def mark_open(self) -> None:
|
||||
@ -312,6 +332,26 @@ class Playlists(dbtables.PlaylistsTable):
|
||||
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):
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@ -62,7 +62,9 @@ from log import log
|
||||
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
|
||||
from music_manager import RowAndTrack, track_sequence
|
||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||
from querylistmodel import QuerylistModel, QuerylistProxyModel
|
||||
from playlists import PlaylistTab
|
||||
from querylists import QuerylistTab
|
||||
from ui import icons_rc # noqa F401
|
||||
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
|
||||
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
|
||||
@ -579,6 +581,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
self.actionMoveUnplayed.triggered.connect(self.move_unplayed)
|
||||
self.actionNewPlaylist.triggered.connect(self.new_playlist)
|
||||
self.actionOpenPlaylist.triggered.connect(self.open_playlist)
|
||||
self.actionOpenQuerylist.triggered.connect(self.open_querylist)
|
||||
self.actionPaste.triggered.connect(self.paste_rows)
|
||||
self.actionPlay_next.triggered.connect(self.play_next)
|
||||
self.actionRenamePlaylist.triggered.connect(self.rename_playlist)
|
||||
@ -648,7 +651,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
|
||||
def create_playlist_tab(self, playlist: Playlists) -> int:
|
||||
"""
|
||||
Take the passed proxy model, create a playlist tab and
|
||||
Take the passed playlist, create a playlist tab and
|
||||
add tab to display. Return index number of tab.
|
||||
"""
|
||||
|
||||
@ -667,6 +670,27 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
|
||||
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:
|
||||
"""
|
||||
If a row or rows are selected, return the row number of the first
|
||||
@ -1116,6 +1140,19 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
|
||||
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:
|
||||
"""Search Songfacts for title"""
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ from PyQt6.QtWidgets import (
|
||||
|
||||
# App imports
|
||||
from audacity_controller import AudacityController
|
||||
from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo
|
||||
from classes import ApplicationError, QueryCol, MusicMusterSignals, PlaylistStyle, TrackInfo
|
||||
from config import Config
|
||||
from dialogs import TrackSelectDialog
|
||||
from helpers import (
|
||||
@ -112,7 +112,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
if self.current_editor:
|
||||
editor = self.current_editor
|
||||
else:
|
||||
if index.column() == Col.INTRO.value:
|
||||
if index.column() == QueryCol.INTRO.value:
|
||||
editor = QDoubleSpinBox(parent)
|
||||
editor.setDecimals(1)
|
||||
editor.setSingleStep(0.1)
|
||||
@ -248,7 +248,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
self.original_model_data = self.base_model.data(
|
||||
edit_index, Qt.ItemDataRole.EditRole
|
||||
)
|
||||
if index.column() == Col.INTRO.value:
|
||||
if index.column() == QueryCol.INTRO.value:
|
||||
if self.original_model_data.value():
|
||||
editor.setValue(self.original_model_data.value() / 1000)
|
||||
else:
|
||||
@ -268,24 +268,6 @@ class PlaylistDelegate(QStyledItemDelegate):
|
||||
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):
|
||||
"""
|
||||
The playlist view
|
||||
|
||||
694
app/querylistmodel.py
Normal file
694
app/querylistmodel.py
Normal file
@ -0,0 +1,694 @@
|
||||
# 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())
|
||||
892
app/querylists.py
Normal file
892
app/querylists.py
Normal file
@ -0,0 +1,892 @@
|
||||
# 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"playlist_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"playlist_col_{column_number}_width"
|
||||
record = Settings.get_setting(session, attr_name)
|
||||
if record.f_int is not None:
|
||||
self.setColumnWidth(column_number, record.f_int)
|
||||
else:
|
||||
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
|
||||
|
||||
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,6 +997,9 @@ padding-left: 8px;</string>
|
||||
<addaction name="actionRenamePlaylist"/>
|
||||
<addaction name="actionDeletePlaylist"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionOpenQuerylist"/>
|
||||
<addaction name="actionManage_querylists"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSave_as_template"/>
|
||||
<addaction name="actionManage_templates"/>
|
||||
<addaction name="separator"/>
|
||||
@ -1140,7 +1143,7 @@ padding-left: 8px;</string>
|
||||
</action>
|
||||
<action name="actionOpenPlaylist">
|
||||
<property name="text">
|
||||
<string>O&pen...</string>
|
||||
<string>Open &playlist...</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionNewPlaylist">
|
||||
@ -1369,6 +1372,16 @@ padding-left: 8px;</string>
|
||||
<string>Import files...</string>
|
||||
</property>
|
||||
</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>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Form implementation generated from reading ui file 'app/ui/main_window.ui'
|
||||
#
|
||||
# Created by: PyQt6 UI code generator 6.8.0
|
||||
# Created by: PyQt6 UI code generator 6.8.1
|
||||
#
|
||||
# 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.
|
||||
@ -531,6 +531,10 @@ class Ui_MainWindow(object):
|
||||
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
|
||||
self.actionImport_files = QtGui.QAction(parent=MainWindow)
|
||||
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.addAction(self.actionInsertTrack)
|
||||
self.menuFile.addAction(self.actionRemove)
|
||||
@ -554,6 +558,9 @@ class Ui_MainWindow(object):
|
||||
self.menuPlaylist.addAction(self.actionRenamePlaylist)
|
||||
self.menuPlaylist.addAction(self.actionDeletePlaylist)
|
||||
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.actionManage_templates)
|
||||
self.menuPlaylist.addSeparator()
|
||||
@ -627,7 +634,7 @@ class Ui_MainWindow(object):
|
||||
self.action_Resume_previous.setText(_translate("MainWindow", "&Resume previous"))
|
||||
self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
|
||||
self.actionTest.setText(_translate("MainWindow", "&Test"))
|
||||
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
|
||||
self.actionOpenPlaylist.setText(_translate("MainWindow", "Open &playlist..."))
|
||||
self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
|
||||
self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
|
||||
self.actionSkipToFade.setText(_translate("MainWindow", "&Skip to start of fade"))
|
||||
@ -677,5 +684,7 @@ class Ui_MainWindow(object):
|
||||
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
|
||||
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
|
||||
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 pyqtgraph import PlotWidget # type: ignore
|
||||
from pyqtgraph import PlotWidget
|
||||
|
||||
28
migrations/env.py.DEBUG
Normal file
28
migrations/env.py.DEBUG
Normal file
@ -0,0 +1,28 @@
|
||||
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,
|
||||
})
|
||||
@ -0,0 +1,52 @@
|
||||
"""Add data for query playlists
|
||||
|
||||
Revision ID: 014f2d4c88a5
|
||||
Revises: 33c04e3c12c8
|
||||
Create Date: 2024-12-30 14:23:36.924478
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '014f2d4c88a5'
|
||||
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! ###
|
||||
op.create_table('queries',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('query', sa.String(length=2048), nullable=False),
|
||||
sa.Column('playlist_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('queries', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_queries_playlist_id'), ['playlist_id'], unique=False)
|
||||
|
||||
# ### 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_index(batch_op.f('ix_queries_playlist_id'))
|
||||
|
||||
op.drop_table('queries')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
Loading…
Reference in New Issue
Block a user