291 lines
8.5 KiB
Python
291 lines
8.5 KiB
Python
# Standard library imports
|
|
# Allow forward reference to PlaylistModel
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
import datetime as dt
|
|
|
|
# PyQt imports
|
|
from PyQt6.QtCore import (
|
|
QAbstractTableModel,
|
|
QModelIndex,
|
|
Qt,
|
|
QVariant,
|
|
)
|
|
from PyQt6.QtGui import (
|
|
QBrush,
|
|
QColor,
|
|
QFont,
|
|
)
|
|
|
|
# Third party imports
|
|
from sqlalchemy.orm.session import Session
|
|
|
|
# import snoop # type: ignore
|
|
|
|
# App imports
|
|
from classes import (
|
|
ApplicationError,
|
|
Filter,
|
|
QueryCol,
|
|
)
|
|
from config import Config
|
|
from helpers import (
|
|
file_is_unreadable,
|
|
get_relative_date,
|
|
ms_to_mmss,
|
|
show_warning,
|
|
)
|
|
from log import log
|
|
from models import db, Playdates, Tracks
|
|
from music_manager import RowAndTrack
|
|
|
|
|
|
@dataclass
|
|
class QueryRow:
|
|
artist: str
|
|
bitrate: int
|
|
duration: int
|
|
lastplayed: Optional[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, session: Session, filter: Filter) -> None:
|
|
"""
|
|
Load query
|
|
"""
|
|
|
|
log.debug(f"QuerylistModel.__init__({filter=})")
|
|
|
|
super().__init__()
|
|
self.session = session
|
|
self.filter = filter
|
|
|
|
self.querylist_rows: dict[int, QueryRow] = {}
|
|
self._selected_rows: set[int] = set()
|
|
|
|
self.load_data()
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<QuerylistModel: filter={self.filter}, {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))
|
|
|
|
# Selected row
|
|
if row in self._selected_rows:
|
|
return QBrush(QColor(Config.COLOUR_QUERYLIST_SELECTED))
|
|
|
|
# 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 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))
|
|
or role
|
|
in [
|
|
Qt.ItemDataRole.CheckStateRole,
|
|
Qt.ItemDataRole.DecorationRole,
|
|
Qt.ItemDataRole.EditRole,
|
|
Qt.ItemDataRole.FontRole,
|
|
Qt.ItemDataRole.ForegroundRole,
|
|
Qt.ItemDataRole.InitialSortOrderRole,
|
|
Qt.ItemDataRole.SizeHintRole,
|
|
Qt.ItemDataRole.StatusTipRole,
|
|
Qt.ItemDataRole.TextAlignmentRole,
|
|
Qt.ItemDataRole.WhatsThisRole,
|
|
]
|
|
):
|
|
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: dict[int, Callable] = {
|
|
int(Qt.ItemDataRole.BackgroundRole): self._background_role,
|
|
int(Qt.ItemDataRole.DisplayRole): self._display_role,
|
|
int(Qt.ItemDataRole.ToolTipRole): self._tooltip_role,
|
|
}
|
|
|
|
if role in dispatch_table:
|
|
return QVariant(dispatch_table[role](row, column, qrow))
|
|
|
|
# Fall through to no-op
|
|
return QVariant()
|
|
|
|
def _display_role(self, row: int, column: int, qrow: QueryRow) -> str:
|
|
"""
|
|
Return text for display
|
|
"""
|
|
|
|
dispatch_table = {
|
|
QueryCol.ARTIST.value: qrow.artist,
|
|
QueryCol.BITRATE.value: str(qrow.bitrate),
|
|
QueryCol.DURATION.value: ms_to_mmss(qrow.duration),
|
|
QueryCol.LAST_PLAYED.value: get_relative_date(qrow.lastplayed),
|
|
QueryCol.TITLE.value: qrow.title,
|
|
}
|
|
if column in dispatch_table:
|
|
return dispatch_table[column]
|
|
|
|
return ""
|
|
|
|
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
|
"""
|
|
Standard model flags
|
|
"""
|
|
|
|
if not index.isValid():
|
|
return Qt.ItemFlag.NoItemFlags
|
|
|
|
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
|
|
|
def get_selected_track_ids(self) -> list[int]:
|
|
"""
|
|
Return a list of track_ids from selected tracks
|
|
"""
|
|
|
|
return [self.querylist_rows[row].track_id for row in self._selected_rows]
|
|
|
|
def headerData(
|
|
self,
|
|
section: int,
|
|
orientation: Qt.Orientation,
|
|
role: int = Qt.ItemDataRole.DisplayRole,
|
|
) -> QVariant:
|
|
"""
|
|
Return text for headers
|
|
"""
|
|
|
|
display_dispatch_table = {
|
|
QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE),
|
|
QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST),
|
|
QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION),
|
|
QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED),
|
|
QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE),
|
|
}
|
|
|
|
if role == Qt.ItemDataRole.DisplayRole:
|
|
if orientation == Qt.Orientation.Horizontal:
|
|
return display_dispatch_table[section]
|
|
else:
|
|
if Config.ROWS_FROM_ZERO:
|
|
return QVariant(str(section))
|
|
else:
|
|
return QVariant(str(section + 1))
|
|
|
|
elif role == Qt.ItemDataRole.FontRole:
|
|
boldfont = QFont()
|
|
boldfont.setBold(True)
|
|
return QVariant(boldfont)
|
|
|
|
return QVariant()
|
|
|
|
def load_data(self) -> None:
|
|
"""
|
|
Populate self.querylist_rows
|
|
"""
|
|
|
|
# Clear any exsiting rows
|
|
self.querylist_rows = {}
|
|
row = 0
|
|
|
|
try:
|
|
results = Tracks.get_filtered_tracks(self.session, self.filter)
|
|
for result in results:
|
|
lastplayed = None
|
|
if hasattr(result, "playdates"):
|
|
pds = result.playdates
|
|
if pds:
|
|
lastplayed = max([a.lastplayed for a in pds])
|
|
queryrow = QueryRow(
|
|
artist=result.artist,
|
|
bitrate=result.bitrate or 0,
|
|
duration=result.duration,
|
|
lastplayed=lastplayed,
|
|
path=result.path,
|
|
title=result.title,
|
|
track_id=result.id,
|
|
)
|
|
|
|
self.querylist_rows[row] = queryrow
|
|
row += 1
|
|
except ApplicationError as e:
|
|
show_warning(None, "Query error", f"Error loading query data ({e})")
|
|
|
|
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
|
|
"""Standard function for view"""
|
|
|
|
return len(self.querylist_rows)
|
|
|
|
def toggle_row_selection(self, row: int) -> None:
|
|
if row in self._selected_rows:
|
|
self._selected_rows.discard(row)
|
|
else:
|
|
self._selected_rows.add(row)
|
|
|
|
# Emit dataChanged for the entire row
|
|
top_left = self.index(row, 0)
|
|
bottom_right = self.index(row, self.columnCount() - 1)
|
|
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
|
|
|
|
def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str | QVariant:
|
|
"""
|
|
Return tooltip. Currently only used for last_played column.
|
|
"""
|
|
|
|
if column != QueryCol.LAST_PLAYED.value:
|
|
return QVariant()
|
|
with db.Session() as session:
|
|
track_id = self.querylist_rows[row].track_id
|
|
if not track_id:
|
|
return QVariant()
|
|
playdates = Playdates.last_playdates(session, track_id)
|
|
return (
|
|
"<br>".join(
|
|
[
|
|
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
|
|
for a in reversed(playdates)
|
|
]
|
|
)
|
|
)
|